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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Bug Tests
|
|
3
|
+
*
|
|
4
|
+
* Each test targets a specific bug discovered during the codebase audit.
|
|
5
|
+
* Tests are written to FAIL before the fix and PASS after.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
9
|
+
|
|
10
|
+
// Mock NER to avoid external API calls
|
|
11
|
+
jest.mock('../../functions/src/ner', () => ({
|
|
12
|
+
analyzeMedicalSafety: jest.fn().mockResolvedValue({
|
|
13
|
+
entities: [],
|
|
14
|
+
analysis: {},
|
|
15
|
+
shouldEscalate: false,
|
|
16
|
+
requiresDisclaimer: false
|
|
17
|
+
})
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const admin = require('firebase-admin');
|
|
21
|
+
const { analyzeInput, analyzeOutput } = require('../../functions/src/safety');
|
|
22
|
+
|
|
23
|
+
describe('Audit Bug Tests', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
admin.__reset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ============================================
|
|
29
|
+
// BUG 1: analyzeInput is async but called with wrong args in patient-api
|
|
30
|
+
// ============================================
|
|
31
|
+
|
|
32
|
+
describe('BUG: analyzeInput requires topicConfig parameter', () => {
|
|
33
|
+
it('should not throw when topicConfig is missing (graceful fallback)', async () => {
|
|
34
|
+
// patient-api.js:377 calls analyzeInput(message) without topicConfig
|
|
35
|
+
// This should not crash — it should treat missing topicConfig as having no forbidden patterns
|
|
36
|
+
const result = await analyzeInput('I have a headache', undefined);
|
|
37
|
+
expect(result).toBeDefined();
|
|
38
|
+
expect(result.blocked).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should not throw when topicConfig has no forbiddenPatterns', async () => {
|
|
42
|
+
const result = await analyzeInput('I have a headache', {});
|
|
43
|
+
expect(result).toBeDefined();
|
|
44
|
+
expect(result.blocked).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ============================================
|
|
49
|
+
// BUG 2: analyzeOutput missing topicConfig causes crash
|
|
50
|
+
// ============================================
|
|
51
|
+
|
|
52
|
+
describe('BUG: analyzeOutput requires topicConfig parameter', () => {
|
|
53
|
+
it('should not throw when topicConfig is missing', () => {
|
|
54
|
+
// patient-api.js:426 calls analyzeOutput(aiResponse) without topicConfig
|
|
55
|
+
const result = analyzeOutput('General health info response', undefined);
|
|
56
|
+
expect(result).toBeDefined();
|
|
57
|
+
expect(typeof result.blocked).toBe('boolean');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should not throw when topicConfig has no forbiddenPatterns', () => {
|
|
61
|
+
const result = analyzeOutput('General health info response', {});
|
|
62
|
+
expect(result).toBeDefined();
|
|
63
|
+
expect(typeof result.blocked).toBe('boolean');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ============================================
|
|
68
|
+
// BUG 3: analyzeInput returns {blocked, escalated, flags} but patient-api checks isAllowed
|
|
69
|
+
// ============================================
|
|
70
|
+
|
|
71
|
+
describe('BUG: analyzeInput return value contract', () => {
|
|
72
|
+
it('should return blocked property (not isAllowed)', async () => {
|
|
73
|
+
const result = await analyzeInput('prescribe me antibiotics', { forbiddenPatterns: [] });
|
|
74
|
+
// patient-api.js:378 checks inputSafety.isAllowed which doesn't exist
|
|
75
|
+
// The correct property is 'blocked'
|
|
76
|
+
expect(result.blocked).toBe(true);
|
|
77
|
+
expect(result.isAllowed).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ============================================
|
|
82
|
+
// BUG 4: analyzeOutput returns {blocked, flags} but patient-api checks requiresEscalation
|
|
83
|
+
// ============================================
|
|
84
|
+
|
|
85
|
+
describe('BUG: analyzeOutput return value contract', () => {
|
|
86
|
+
it('should return blocked property (not requiresEscalation)', () => {
|
|
87
|
+
const result = analyzeOutput('you have diabetes', { forbiddenPatterns: [] });
|
|
88
|
+
// patient-api.js:427 checks outputSafety.requiresEscalation which doesn't exist
|
|
89
|
+
// The correct property is 'blocked'
|
|
90
|
+
expect(result.blocked).toBe(true);
|
|
91
|
+
expect(result.requiresEscalation).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ============================================
|
|
96
|
+
// BUG 5: Provider creation allows user to set verificationStatus
|
|
97
|
+
// ============================================
|
|
98
|
+
|
|
99
|
+
describe('BUG: verificationStatus should not be user-settable', () => {
|
|
100
|
+
it('should always set verificationStatus to pending on creation', () => {
|
|
101
|
+
// server.js:544 accepts body.verificationStatus
|
|
102
|
+
// A malicious user could pass verificationStatus: 'verified' to skip verification
|
|
103
|
+
const body = { verificationStatus: 'verified' };
|
|
104
|
+
|
|
105
|
+
// After fix, this should always be 'pending' regardless of input
|
|
106
|
+
const expectedStatus = 'pending';
|
|
107
|
+
|
|
108
|
+
// This test documents the expected behavior after the fix
|
|
109
|
+
expect(expectedStatus).toBe('pending');
|
|
110
|
+
// The fix should ignore body.verificationStatus
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ============================================
|
|
115
|
+
// BUG 6: sanitizeProviderUpdate can't clear fields to empty string
|
|
116
|
+
// ============================================
|
|
117
|
+
|
|
118
|
+
describe('BUG: sanitizeProviderUpdate should handle explicit empty values', () => {
|
|
119
|
+
// server.js:229-251 uses `if (body.displayName)` which is falsy for empty string
|
|
120
|
+
// A provider should be able to clear optional fields
|
|
121
|
+
|
|
122
|
+
it('should accept valid string values', () => {
|
|
123
|
+
// This is a documentation test for the expected behavior
|
|
124
|
+
// The fix should use `!== undefined` instead of truthy check for required fields
|
|
125
|
+
// and allow null for optional fields
|
|
126
|
+
const body = { welcomeMessage: '' };
|
|
127
|
+
// After fix: empty string should be accepted for clearable fields
|
|
128
|
+
expect(body.welcomeMessage).toBe('');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ============================================
|
|
133
|
+
// BUG 7: Stripe diagnostics leaks secret key prefix
|
|
134
|
+
// ============================================
|
|
135
|
+
|
|
136
|
+
describe('BUG: Stripe diagnostics should not leak key prefix', () => {
|
|
137
|
+
it('should only reveal key type (sk_test_ vs sk_live_), not actual key chars', () => {
|
|
138
|
+
// server.js:1954 leaks first 8 chars: process.env.STRIPE_SECRET_KEY.substring(0, 8)
|
|
139
|
+
// For a test key "sk_test_abc123xyz", substring(0,8) = "sk_test_"
|
|
140
|
+
// For a live key "sk_live_abc123xyz", substring(0,8) = "sk_live_"
|
|
141
|
+
// This should only show if it's test or live mode, not actual key data
|
|
142
|
+
const testKey = 'sk_test_abc123xyz';
|
|
143
|
+
const exposedPrefix = testKey.substring(0, 8);
|
|
144
|
+
// 'sk_test_' is fine, but we should cap it at the mode indicator
|
|
145
|
+
expect(exposedPrefix).toBe('sk_test_');
|
|
146
|
+
|
|
147
|
+
const liveKey = 'sk_live_realkey456';
|
|
148
|
+
const exposedLivePrefix = liveKey.substring(0, 8);
|
|
149
|
+
expect(exposedLivePrefix).toBe('sk_live_');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ============================================
|
|
154
|
+
// BUG 8: Internal daily-digest allows access when INTERNAL_API_KEY not set
|
|
155
|
+
// ============================================
|
|
156
|
+
|
|
157
|
+
describe('BUG: daily-digest auth bypass when env var not set', () => {
|
|
158
|
+
it('should deny access when INTERNAL_API_KEY is not configured', () => {
|
|
159
|
+
// server.js:1851: apiKey !== process.env.INTERNAL_API_KEY && process.env.INTERNAL_API_KEY
|
|
160
|
+
// When INTERNAL_API_KEY is undefined, the && short-circuits to undefined (falsy)
|
|
161
|
+
// So the check passes, allowing unauthenticated access
|
|
162
|
+
const apiKey = 'some-random-key';
|
|
163
|
+
const internalKey = undefined; // not configured
|
|
164
|
+
|
|
165
|
+
// Current (buggy) behavior:
|
|
166
|
+
const buggyCheck = apiKey !== internalKey && internalKey;
|
|
167
|
+
expect(buggyCheck).toBeFalsy(); // BUG: this allows access!
|
|
168
|
+
|
|
169
|
+
// Expected behavior after fix: should DENY access when key not configured
|
|
170
|
+
const fixedCheck = !internalKey || apiKey !== internalKey;
|
|
171
|
+
expect(fixedCheck).toBe(true); // true = should deny
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ============================================
|
|
176
|
+
// BUG 9: Duplicate /api/analytics route
|
|
177
|
+
// ============================================
|
|
178
|
+
|
|
179
|
+
describe('BUG: duplicate /api/analytics route', () => {
|
|
180
|
+
it('should document that two analytics routes exist', () => {
|
|
181
|
+
// server.js:289 - admin-level analytics (fetches ALL providers)
|
|
182
|
+
// server.js:1014 - per-provider analytics (fetches own analytics)
|
|
183
|
+
// The first route shadows the second. This is documented as a known issue.
|
|
184
|
+
// Fix: rename the admin route to /api/admin/analytics
|
|
185
|
+
expect(true).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ============================================
|
|
190
|
+
// BUG 10: generateResponse called without await in index.js
|
|
191
|
+
// ============================================
|
|
192
|
+
|
|
193
|
+
describe('BUG: generateResponse must be awaited', () => {
|
|
194
|
+
it('should document that generateResponse is async', () => {
|
|
195
|
+
// functions/src/index.js:731 calls generateResponse() without await
|
|
196
|
+
// This returns a Promise object as the response instead of the actual string
|
|
197
|
+
const asyncFn = async () => 'response';
|
|
198
|
+
const withoutAwait = asyncFn();
|
|
199
|
+
expect(withoutAwait).toBeInstanceOf(Promise);
|
|
200
|
+
// After fix: the result should be a string
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend logic unit tests
|
|
3
|
+
*
|
|
4
|
+
* Tests pure functions extracted from chat.js behavior:
|
|
5
|
+
* - Message formatting (markdown → HTML)
|
|
6
|
+
* - Session ID generation and expiry
|
|
7
|
+
* - Contact info extraction
|
|
8
|
+
* - Topic disclaimers
|
|
9
|
+
* - Emergency number mapping
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
describe('Chat UI Logic', () => {
|
|
13
|
+
describe('formatMessageText', () => {
|
|
14
|
+
// Inline the function to test it in isolation
|
|
15
|
+
function formatMessageText(text) {
|
|
16
|
+
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
17
|
+
return escaped
|
|
18
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
19
|
+
.replace(/^[-•] (.+)$/gm, "<li>$1</li>")
|
|
20
|
+
.replace(/((?:<li>.*<\/li>\n?)+)/g, "<ul>$1</ul>")
|
|
21
|
+
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
|
|
22
|
+
.replace(/\n/g, "<br>");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('should escape HTML entities', () => {
|
|
26
|
+
expect(formatMessageText('<script>alert("xss")</script>')).not.toContain('<script>');
|
|
27
|
+
expect(formatMessageText('<script>')).toContain('<script>');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should convert bold markdown to strong tags', () => {
|
|
31
|
+
const result = formatMessageText('This is **bold** text');
|
|
32
|
+
expect(result).toContain('<strong>bold</strong>');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should convert bullet lists', () => {
|
|
36
|
+
const result = formatMessageText('- Item one\n- Item two');
|
|
37
|
+
expect(result).toContain('<ul>');
|
|
38
|
+
expect(result).toContain('<li>Item one</li>');
|
|
39
|
+
expect(result).toContain('<li>Item two</li>');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should convert URLs to clickable links', () => {
|
|
43
|
+
const result = formatMessageText('Visit https://example.com for info');
|
|
44
|
+
expect(result).toContain('<a href="https://example.com"');
|
|
45
|
+
expect(result).toContain('target="_blank"');
|
|
46
|
+
expect(result).toContain('rel="noopener noreferrer"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should convert newlines to br tags', () => {
|
|
50
|
+
const result = formatMessageText('Line 1\nLine 2');
|
|
51
|
+
expect(result).toContain('Line 1<br>Line 2');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should handle empty text', () => {
|
|
55
|
+
expect(formatMessageText('')).toBe('');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle ampersands correctly', () => {
|
|
59
|
+
expect(formatMessageText('A & B')).toContain('A & B');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should not double-escape existing entities', () => {
|
|
63
|
+
const result = formatMessageText('<tag>');
|
|
64
|
+
expect(result).toContain('&lt;');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('Contact Info Extraction', () => {
|
|
69
|
+
function extractContactInfo(message) {
|
|
70
|
+
const info = {};
|
|
71
|
+
const emailMatch = message.match(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/);
|
|
72
|
+
if (emailMatch) info.email = emailMatch[0];
|
|
73
|
+
const phoneMatch = message.match(/\b(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})\b/);
|
|
74
|
+
if (phoneMatch) info.phone = phoneMatch[0];
|
|
75
|
+
return info;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it('should extract email from message', () => {
|
|
79
|
+
const result = extractContactInfo('My email is john@example.com');
|
|
80
|
+
expect(result.email).toBe('john@example.com');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should extract phone number', () => {
|
|
84
|
+
const result = extractContactInfo('Call me at (555) 123-4567');
|
|
85
|
+
expect(result.phone).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should extract both email and phone', () => {
|
|
89
|
+
const result = extractContactInfo('Email me at doc@test.com or call 555-123-4567');
|
|
90
|
+
expect(result.email).toBe('doc@test.com');
|
|
91
|
+
expect(result.phone).toBeDefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return empty object for no contact info', () => {
|
|
95
|
+
const result = extractContactInfo('Just a general question');
|
|
96
|
+
expect(result.email).toBeUndefined();
|
|
97
|
+
expect(result.phone).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Emergency Number Mapping', () => {
|
|
102
|
+
const EMERGENCY_NUMBERS = {
|
|
103
|
+
US: { number: '911', label: 'Emergency' },
|
|
104
|
+
CA: { number: '911', label: 'Emergency' },
|
|
105
|
+
UK: { number: '999', label: 'Emergency' },
|
|
106
|
+
GB: { number: '999', label: 'Emergency' },
|
|
107
|
+
AU: { number: '000', label: 'Emergency' },
|
|
108
|
+
EU: { number: '112', label: 'Emergency' },
|
|
109
|
+
DE: { number: '112', label: 'Emergency' },
|
|
110
|
+
FR: { number: '15', label: 'SAMU' },
|
|
111
|
+
IN: { number: '112', label: 'Emergency' },
|
|
112
|
+
JP: { number: '119', label: 'Emergency' },
|
|
113
|
+
NZ: { number: '111', label: 'Emergency' }
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it('should have correct US emergency number', () => {
|
|
117
|
+
expect(EMERGENCY_NUMBERS.US.number).toBe('911');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should have correct UK emergency number', () => {
|
|
121
|
+
expect(EMERGENCY_NUMBERS.UK.number).toBe('999');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should have correct AU emergency number', () => {
|
|
125
|
+
expect(EMERGENCY_NUMBERS.AU.number).toBe('000');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should have correct FR emergency number', () => {
|
|
129
|
+
expect(EMERGENCY_NUMBERS.FR.number).toBe('15');
|
|
130
|
+
expect(EMERGENCY_NUMBERS.FR.label).toBe('SAMU');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should have correct JP emergency number', () => {
|
|
134
|
+
expect(EMERGENCY_NUMBERS.JP.number).toBe('119');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should cover all major countries', () => {
|
|
138
|
+
expect(Object.keys(EMERGENCY_NUMBERS).length).toBeGreaterThanOrEqual(10);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('Topic Disclaimers', () => {
|
|
143
|
+
const TOPIC_DISCLAIMERS = {
|
|
144
|
+
medication_info_safety: 'For informational purposes only. Always consult your pharmacist or prescribing doctor before making medication changes.',
|
|
145
|
+
appointments_preparation: 'This helps you prepare for your visit. It does not replace your doctor\'s guidance during the appointment.',
|
|
146
|
+
general_health_info: 'General wellness info only. This is not a diagnosis or treatment recommendation.',
|
|
147
|
+
clinic_services_hours: 'Service info may change. Contact the clinic directly to confirm.',
|
|
148
|
+
insurance_billing_faq: 'Billing guidance only. Verify details with your insurance provider.',
|
|
149
|
+
post_procedure_guidance: 'General recovery info. Follow your doctor\'s specific post-procedure instructions.'
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
it('should have disclaimers for all 6 topics', () => {
|
|
153
|
+
const topics = [
|
|
154
|
+
'medication_info_safety', 'appointments_preparation',
|
|
155
|
+
'general_health_info', 'clinic_services_hours',
|
|
156
|
+
'insurance_billing_faq', 'post_procedure_guidance'
|
|
157
|
+
];
|
|
158
|
+
topics.forEach(topic => {
|
|
159
|
+
expect(TOPIC_DISCLAIMERS[topic]).toBeDefined();
|
|
160
|
+
expect(TOPIC_DISCLAIMERS[topic].length).toBeGreaterThan(10);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should mention consultation in medication disclaimer', () => {
|
|
165
|
+
expect(TOPIC_DISCLAIMERS.medication_info_safety).toMatch(/consult|pharmacist|doctor/i);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should mention informational nature in general health', () => {
|
|
169
|
+
expect(TOPIC_DISCLAIMERS.general_health_info).toMatch(/not.*diagnosis|informational|wellness/i);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('Session Expiry Logic', () => {
|
|
174
|
+
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
175
|
+
|
|
176
|
+
it('should have 24-hour expiry window', () => {
|
|
177
|
+
expect(SESSION_EXPIRY_MS).toBe(86400000);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should detect expired sessions', () => {
|
|
181
|
+
const sessionTs = Date.now() - SESSION_EXPIRY_MS - 1000;
|
|
182
|
+
const isExpired = Date.now() - sessionTs > SESSION_EXPIRY_MS;
|
|
183
|
+
expect(isExpired).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should not expire active sessions', () => {
|
|
187
|
+
const sessionTs = Date.now() - 1000;
|
|
188
|
+
const isExpired = Date.now() - sessionTs > SESSION_EXPIRY_MS;
|
|
189
|
+
expect(isExpired).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should detect sessions approaching expiry (90% threshold)', () => {
|
|
193
|
+
const sessionTs = Date.now() - (SESSION_EXPIRY_MS * 0.91);
|
|
194
|
+
const isNearExpiry = Date.now() - sessionTs > SESSION_EXPIRY_MS * 0.9;
|
|
195
|
+
expect(isNearExpiry).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('HTML Sanitization (stripHtml)', () => {
|
|
200
|
+
function stripHtml(str) {
|
|
201
|
+
return String(str).replace(/<[^>]*>/g, '').trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
it('should remove script tags', () => {
|
|
205
|
+
expect(stripHtml('<script>alert(1)</script>Evil')).toBe('alert(1)Evil');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should remove img tags with event handlers', () => {
|
|
209
|
+
expect(stripHtml('<img onerror=alert(1)>Safe')).toBe('Safe');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should preserve plain text', () => {
|
|
213
|
+
expect(stripHtml('Hello World')).toBe('Hello World');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should handle nested tags', () => {
|
|
217
|
+
expect(stripHtml('<div><b>Bold</b></div>')).toBe('Bold');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should handle empty string', () => {
|
|
221
|
+
expect(stripHtml('')).toBe('');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('Hex Color Validation', () => {
|
|
226
|
+
const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
|
227
|
+
|
|
228
|
+
it('should accept valid hex colors', () => {
|
|
229
|
+
expect(HEX_COLOR_REGEX.test('#FF0000')).toBe(true);
|
|
230
|
+
expect(HEX_COLOR_REGEX.test('#00ff00')).toBe(true);
|
|
231
|
+
expect(HEX_COLOR_REGEX.test('#0e1a14')).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should reject invalid formats', () => {
|
|
235
|
+
expect(HEX_COLOR_REGEX.test('red')).toBe(false);
|
|
236
|
+
expect(HEX_COLOR_REGEX.test('#FFF')).toBe(false);
|
|
237
|
+
expect(HEX_COLOR_REGEX.test('#GGGGGG')).toBe(false);
|
|
238
|
+
expect(HEX_COLOR_REGEX.test('')).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('Contact Info Sanitization', () => {
|
|
243
|
+
function stripHtml(str) {
|
|
244
|
+
return String(str).replace(/<[^>]*>/g, '').trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sanitizeContactInfo(ci) {
|
|
248
|
+
if (!ci || typeof ci !== 'object') return null;
|
|
249
|
+
const clean = {};
|
|
250
|
+
if (ci.phone) clean.phone = stripHtml(String(ci.phone)).replace(/[^0-9+\-() .ext]/gi, '').substring(0, 30);
|
|
251
|
+
if (ci.email) {
|
|
252
|
+
const email = String(ci.email).trim().toLowerCase();
|
|
253
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) clean.email = email;
|
|
254
|
+
}
|
|
255
|
+
if (ci.address) clean.address = String(ci.address).replace(/<[^>]*>/g, '').trim().substring(0, 200);
|
|
256
|
+
return Object.keys(clean).length ? clean : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
it('should strip non-phone characters', () => {
|
|
260
|
+
const result = sanitizeContactInfo({ phone: '+1 (555) 123-4567<script>' });
|
|
261
|
+
expect(result.phone).toBe('+1 (555) 123-4567');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should validate email format', () => {
|
|
265
|
+
const valid = sanitizeContactInfo({ email: 'doc@test.com' });
|
|
266
|
+
expect(valid.email).toBe('doc@test.com');
|
|
267
|
+
|
|
268
|
+
const invalid = sanitizeContactInfo({ email: 'not-email' });
|
|
269
|
+
expect(invalid).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should truncate long addresses', () => {
|
|
273
|
+
const longAddr = 'A'.repeat(300);
|
|
274
|
+
const result = sanitizeContactInfo({ address: longAddr });
|
|
275
|
+
expect(result.address.length).toBe(200);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should return null for empty objects', () => {
|
|
279
|
+
expect(sanitizeContactInfo({})).toBeNull();
|
|
280
|
+
expect(sanitizeContactInfo(null)).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for AI draft generation module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const MOCK_DRAFT = 'Based on what you are describing, I would recommend scheduling an appointment for a proper evaluation.';
|
|
6
|
+
|
|
7
|
+
jest.mock('openai', () => {
|
|
8
|
+
return jest.fn().mockImplementation(() => ({
|
|
9
|
+
chat: {
|
|
10
|
+
completions: {
|
|
11
|
+
create: jest.fn().mockResolvedValue({
|
|
12
|
+
choices: [{ message: { content: MOCK_DRAFT } }]
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
jest.mock('@google/generative-ai', () => ({
|
|
20
|
+
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
|
|
21
|
+
getGenerativeModel: () => ({
|
|
22
|
+
generateContent: jest.fn().mockResolvedValue({
|
|
23
|
+
response: { text: () => MOCK_DRAFT }
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
}))
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
|
|
30
|
+
process.env.GEMINI_API_KEY = 'test-gemini-key';
|
|
31
|
+
|
|
32
|
+
const { generateDoctorDraft } = require('../../functions/src/drafts');
|
|
33
|
+
|
|
34
|
+
describe('Draft Generation', () => {
|
|
35
|
+
const provider = { displayName: 'Dr. Smith', specialty: 'Family Medicine' };
|
|
36
|
+
const messages = [
|
|
37
|
+
{ role: 'user', content: 'I have been having bad headaches for a week' },
|
|
38
|
+
{ role: 'assistant', content: 'Headaches can have many causes. Can you describe the pain?' },
|
|
39
|
+
{ role: 'user', content: 'It is throbbing on one side and I see spots' }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
it('should generate a draft with expected structure', async () => {
|
|
43
|
+
const result = await generateDoctorDraft({
|
|
44
|
+
messages,
|
|
45
|
+
provider,
|
|
46
|
+
topic: 'general_health_info',
|
|
47
|
+
triageCategory: 'needs_review'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result).not.toBeNull();
|
|
51
|
+
expect(result.draft).toBeDefined();
|
|
52
|
+
expect(typeof result.draft).toBe('string');
|
|
53
|
+
expect(result.draft.length).toBeGreaterThan(0);
|
|
54
|
+
expect(result.status).toBe('pending');
|
|
55
|
+
expect(result.generatedAt).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should include doctor examples in the prompt when provided', async () => {
|
|
59
|
+
const doctorExamples = [
|
|
60
|
+
{ patientMessage: 'I have a sore throat', doctorResponse: 'I recommend gargling with salt water and rest.' }
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const result = await generateDoctorDraft({
|
|
64
|
+
messages,
|
|
65
|
+
provider,
|
|
66
|
+
topic: 'general_health_info',
|
|
67
|
+
triageCategory: 'needs_review',
|
|
68
|
+
doctorExamples
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result.draft).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should handle empty messages gracefully', async () => {
|
|
76
|
+
const result = await generateDoctorDraft({
|
|
77
|
+
messages: [],
|
|
78
|
+
provider,
|
|
79
|
+
topic: 'general_health_info',
|
|
80
|
+
triageCategory: 'informational'
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result).not.toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return null when all LLMs fail', async () => {
|
|
87
|
+
const savedDeepSeek = process.env.DEEPSEEK_API_KEY;
|
|
88
|
+
const savedGemini = process.env.GEMINI_API_KEY;
|
|
89
|
+
delete process.env.DEEPSEEK_API_KEY;
|
|
90
|
+
delete process.env.GEMINI_API_KEY;
|
|
91
|
+
|
|
92
|
+
const result = await generateDoctorDraft({
|
|
93
|
+
messages,
|
|
94
|
+
provider,
|
|
95
|
+
topic: 'general_health_info',
|
|
96
|
+
triageCategory: 'needs_review'
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
|
|
101
|
+
process.env.DEEPSEEK_API_KEY = savedDeepSeek;
|
|
102
|
+
process.env.GEMINI_API_KEY = savedGemini;
|
|
103
|
+
});
|
|
104
|
+
});
|