From aaecb5067cc8e82ae589d80200e93e00a3674491 Mon Sep 17 00:00:00 2001 From: chehanw Date: Tue, 27 Jan 2026 16:21:53 -0800 Subject: [PATCH 1/2] Changed user flow instructions for enrollment to include medical history questionnaire via chatbot --- homeflow/app/(onboarding)/chat.tsx | 118 +++++++++++++++++++++++------ 1 file changed, 96 insertions(+), 22 deletions(-) diff --git a/homeflow/app/(onboarding)/chat.tsx b/homeflow/app/(onboarding)/chat.tsx index 8437a3a..c978b20 100644 --- a/homeflow/app/(onboarding)/chat.tsx +++ b/homeflow/app/(onboarding)/chat.tsx @@ -25,10 +25,38 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; /** * Combined system prompt for eligibility + medical history + * + * DATA SOURCE NOTES: + * - From Apple HealthKit (automatic): Age/DOB, biological sex, height, weight, BMI + * - From Chatbot (this prompt): Everything else - medications, conditions, labs, surgeries + * + * ============================================================================= + * TODO / DEVELOPMENT NOTES: + * ============================================================================= + * + * 1. CLINICAL RECORDS ACCESS (Future Enhancement) + * We are actively working to obtain Apple's Clinical Health Records entitlement + * approval. Once approved, we can pull medications, lab results, conditions, + * and procedures directly from connected health systems (MyChart, Epic, etc.). + * + * Current chatbot-based medical history collection is: + * - A temporary solution while we await Clinical Records approval + * - A fallback for users who haven't connected their health records to Apple Health + * + * When Clinical Records access is available, update flow to: + * - First attempt to pull data from Clinical Records + * - Use chatbot only to fill gaps where data is missing + * + * 2. ELIGIBILITY CRITERIA (Pending from PI) + * The eligibility questions below are preliminary placeholders. + * Final eligibility criteria will be provided by the Principal Investigator (PI). + * Update the Phase 1 section of this prompt once official criteria are received. + * + * ============================================================================= */ const SYSTEM_PROMPT = `You are a friendly research assistant helping to screen and enroll participants in the HomeFlow BPH study. Your goal is to: 1. First, check eligibility through natural conversation -2. Then, if eligible, collect medical history +2. Then, if eligible, collect comprehensive medical history ## Study Information - Name: ${STUDY_INFO.name} @@ -40,33 +68,79 @@ Check these naturally (don't read a checklist): 1. Has iPhone with iOS 15+ (required) 2. Has Apple Watch (required) 3. Has BPH diagnosis OR experiencing urinary symptoms like frequent urination, weak stream, nighttime urination (required) -4. Considering or scheduled for bladder outlet surgery like TURP, laser therapy, UroLift, Rezum (required) -5. Willing to use Throne uroflow device (optional - okay to skip) +4. Considering or scheduled for bladder outlet surgery like TURP, HoLEP, GreenLight laser, UroLift, Rezum, Aquablation (required) +5. Willing to use Throne uroflow device at home (optional - explain it's a portable urine flow meter) ## Phase 2: Medical History (if eligible) -Collect conversationally: -1. Current medications (especially for BPH/prostate) -2. Other medical conditions -3. Allergies -4. Previous surgeries -5. BPH treatment history - -## Guidelines + +### Data We Get Automatically from Apple Health (DO NOT ASK): +- Age / Date of Birth +- Biological Sex +- Height +- Weight / BMI + +### Data You MUST Collect (not available from Apple Health): + +#### 2.1 Demographics +- Full name (for study records) +- Ethnicity: Hispanic/Latino or Not Hispanic/Latino +- Race + +#### 2.2 BPH/LUTS Medications (BE THOROUGH - ask about each category) +Go through each medication class: +1. Alpha blockers: "Are you taking tamsulosin (Flomax), alfuzosin (Uroxatral), silodosin (Rapaflo), doxazosin, or terazosin?" +2. 5-alpha reductase inhibitors: "Are you taking finasteride (Proscar) or dutasteride (Avodart)?" +3. Anticholinergics: "Are you taking oxybutynin (Ditropan), tolterodine (Detrol), solifenacin (Vesicare), or trospium (Sanctura)?" +4. Beta-3 agonists: "Are you taking mirabegron (Myrbetriq) or vibegron (Gemtesa)?" +5. Any other bladder or prostate medications + +#### 2.3 Surgical History +- Prior BPH/prostate surgeries: Ask about TURP, HoLEP, GreenLight, UroLift, Rezum, Aquablation, or any other prostate procedures. Get type AND approximate date. +- General surgical history: Any other past surgeries (type and approximate year) + +#### 2.4 Lab Values (ask if they know these) +- PSA (Prostate Specific Antigen): Most recent value and when it was done. Explain: "This is a blood test often done for prostate screening." +- Urinalysis: Any recent urine test results, especially if anything abnormal was found + +#### 2.5 Key Medical Conditions (CRITICAL - must ask about these specifically) +- **Diabetes**: Ask directly! If yes, ask about HbA1c level (explain: "This is a blood sugar control number, usually between 5-10%") +- **Hypertension**: High blood pressure - are they diagnosed? Is it controlled with medication? +- Other significant conditions + +#### 2.6 Clinical Measurements (if they've had these tests) +- PVR (Post-Void Residual) or bladder scan: "Have you had a bladder scan after urinating? If so, what was the residual volume in mL?" +- Clinic uroflow: "Have you done a urine flow test at your doctor's office? If so, what was your Qmax (maximum flow rate)?" +- Mobility status: How active are they? Any limitations? + +#### 2.7 Upcoming Surgery +- Date of scheduled BPH surgery (if known) +- Type of surgery planned (TURP, HoLEP, UroLift, Rezum, etc.) + +## Conversation Guidelines - Be warm, conversational, and empathetic -- Ask one or two things at a time -- If they mention symptoms, acknowledge them -- If ineligible, be kind and explain why -- Don't give medical advice +- Ask 2-3 related items at a time, don't overwhelm +- Group questions logically (all medications together, then conditions, etc.) +- Acknowledge symptoms supportively when mentioned +- If they don't know a value (like PSA or HbA1c), that's OK - just note "unknown" and continue +- NEVER give medical advice or interpret their values +- If ineligible, be kind and explain why clearly + +## Important Response Markers (include these exact phrases) +When eligibility is confirmed: [ELIGIBLE] +When ineligible: [INELIGIBLE] +When ALL medical history sections are complete: [HISTORY_COMPLETE] -## Important Responses -When eligibility is confirmed, include the exact phrase: [ELIGIBLE] -When ineligible, include: [INELIGIBLE] -When medical history is complete, include: [HISTORY_COMPLETE] +## Conversation Flow Example +1. Start with eligibility (devices, diagnosis, surgery plans) +2. Transition after eligible: "Great news! You're eligible for the study. [ELIGIBLE] Now I need to collect some medical history. We'll automatically get things like your age and weight from Apple Health, but I need to ask you about medications, conditions, and a few other things..." +3. Work through sections in order: Demographics → Medications → Surgeries → Labs → Conditions → Clinical data → Planned surgery +4. Before finishing, summarize: "Let me confirm what I have..." then list key points +5. End with: "I have everything I need. [HISTORY_COMPLETE] You can tap Continue to proceed." -These markers help the app know when to enable the Continue button. +## Start the Conversation +"Hi! I'm here to help you join the HomeFlow study. This is a research study that tracks urinary symptoms before and after prostate surgery. Let me ask a few quick questions to make sure this study is right for you. -## Start the conversation -Open with something like: "Hi! I'm here to help you join the HomeFlow study. Let me ask a few questions to make sure this study is a good fit for you. First, are you using an iPhone?"`; +First - are you using an iPhone with iOS 15 or later?"`; type ChatPhase = 'eligibility' | 'medical_history' | 'complete' | 'ineligible'; From 5882a2c877bc67646406cb963049af58e720fa7c Mon Sep 17 00:00:00 2001 From: chehanw Date: Tue, 27 Jan 2026 17:18:10 -0800 Subject: [PATCH 2/2] create unit testing framework and smooth out chatbot + IPSS flow --- homeflow/CLAUDE.md | 13 + homeflow/app/(onboarding)/baseline-survey.tsx | 3 + homeflow/app/(onboarding)/chat.tsx | 1 + .../__tests__/account-service.test.ts | 385 ++++++++++++ .../__tests__/backend-factory.test.ts | 94 +++ .../__tests__/consent-service.test.ts | 357 +++++++++++ .../lib/services/__tests__/jest.config.js | 34 + homeflow/lib/services/__tests__/jest.setup.js | 39 ++ .../services/__tests__/local-storage.test.ts | 444 +++++++++++++ .../__tests__/onboarding-service.test.ts | 587 ++++++++++++++++++ .../services/__tests__/throne-service.test.ts | 351 +++++++++++ homeflow/package-lock.json | 159 +++++ homeflow/package.json | 6 +- .../packages/chat/src/components/ChatView.tsx | 6 + homeflow/packages/chat/src/types.ts | 2 + .../src/components/QuestionnaireForm.tsx | 10 +- .../packages/questionnaire/src/types/index.ts | 3 + 17 files changed, 2491 insertions(+), 3 deletions(-) create mode 100644 homeflow/lib/services/__tests__/account-service.test.ts create mode 100644 homeflow/lib/services/__tests__/backend-factory.test.ts create mode 100644 homeflow/lib/services/__tests__/consent-service.test.ts create mode 100644 homeflow/lib/services/__tests__/jest.config.js create mode 100644 homeflow/lib/services/__tests__/jest.setup.js create mode 100644 homeflow/lib/services/__tests__/local-storage.test.ts create mode 100644 homeflow/lib/services/__tests__/onboarding-service.test.ts create mode 100644 homeflow/lib/services/__tests__/throne-service.test.ts diff --git a/homeflow/CLAUDE.md b/homeflow/CLAUDE.md index 9ef56a6..358271c 100644 --- a/homeflow/CLAUDE.md +++ b/homeflow/CLAUDE.md @@ -124,3 +124,16 @@ Claude Code agents for common tasks. Invoke with `/agent-name`. | ux-planner | `/ux-planner` | Design user flows and engagement strategies | Agent definitions are in `.claude/commands/`. + +## Testing Strategy + +### Running Tests +- `npm test`: Runs **ALL** tests (services + workspaces). This is the command used by CI. +- `npm run test:services`: Runs only the service-layer tests (Jest). + +### Feature Flags in Tests +We use a feature flag pattern to handle tests for features that are not yet implemented (e.g., Throne API integration, Firebase backend). + +- **Pattern**: Tests for unimplemented features are wrapped in feature flag checks (e.g., `FEATURE_FLAGS.THRONE_API_IMPLEMENTED`). +- **Default State**: Flags are set to `false`, causing these tests to rely on stub behavior or be skipped. +- **Action Required**: When you implement a feature, set the corresponding flag to `true` in the test file and update the test expectations to verify the real implementation. diff --git a/homeflow/app/(onboarding)/baseline-survey.tsx b/homeflow/app/(onboarding)/baseline-survey.tsx index fa126eb..486771a 100644 --- a/homeflow/app/(onboarding)/baseline-survey.tsx +++ b/homeflow/app/(onboarding)/baseline-survey.tsx @@ -214,6 +214,9 @@ export default function BaselineSurveyScreen() { questionnaire={IPSS_QUESTIONNAIRE} onResult={handleSubmit} submitButtonText="Submit Survey" + // Fix for scroll issue: Ensure keyboard doesn't cover input and content scrolls past footer + keyboardVerticalOffset={100} + scrollContentStyle={{ paddingBottom: 120 }} /> diff --git a/homeflow/app/(onboarding)/chat.tsx b/homeflow/app/(onboarding)/chat.tsx index c978b20..cdbfae6 100644 --- a/homeflow/app/(onboarding)/chat.tsx +++ b/homeflow/app/(onboarding)/chat.tsx @@ -270,6 +270,7 @@ export default function OnboardingChatScreen() { provider={provider} systemPrompt={SYSTEM_PROMPT} placeholder="Type your response..." + onResponse={checkForMarkers} emptyState={ diff --git a/homeflow/lib/services/__tests__/account-service.test.ts b/homeflow/lib/services/__tests__/account-service.test.ts new file mode 100644 index 0000000..b92ad01 --- /dev/null +++ b/homeflow/lib/services/__tests__/account-service.test.ts @@ -0,0 +1,385 @@ +/** + * Unit Tests for Account Service + * + * The AccountService manages local user account/profile storage for the research study. + * It provides a Firebase-compatible interface but stores data locally using AsyncStorage. + * + * Key behaviors tested: + * - Lazy initialization from AsyncStorage on first access + * - Profile CRUD operations (create, read, update, delete) + * - Auto-generation of user IDs and timestamps + * - Persistence to AsyncStorage + * - Error handling for storage failures + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { STORAGE_KEYS } from '../../constants'; + +/** + * Helper to create a fresh AccountService instance for each test. + * Uses jest.resetModules() to clear the singleton cache, ensuring + * each test starts with a clean, uninitialized service. + */ +const createAccountService = () => { + jest.resetModules(); + const { AccountService } = require('../account-service'); + return AccountService; +}; + +describe('AccountService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: simulate empty storage (no existing profile) + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + }); + + /** + * Tests for isAuthenticated() + * + * This method checks whether a user profile exists, indicating + * the user has completed account creation. Used by auth guards + * to determine if the user can access protected screens. + */ + describe('isAuthenticated', () => { + /** + * Verifies that a new/fresh app state (no profile in storage) + * correctly reports as not authenticated. + */ + it('should return false when no profile exists in storage', async () => { + const service = createAccountService(); + const result = await service.isAuthenticated(); + expect(result).toBe(false); + }); + + /** + * Verifies that when a profile exists in AsyncStorage, + * the service correctly loads it and reports authenticated. + */ + it('should return true when a valid profile exists in storage', async () => { + const mockProfile = { + id: 'local_123', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockProfile)); + + const service = createAccountService(); + const result = await service.isAuthenticated(); + expect(result).toBe(true); + }); + + /** + * Verifies lazy initialization: the service should only read from + * AsyncStorage once, then cache the result for subsequent calls. + * This prevents unnecessary storage reads on repeated checks. + */ + it('should only initialize once (lazy initialization caching)', async () => { + const service = createAccountService(); + + // Call multiple times + await service.isAuthenticated(); + await service.isAuthenticated(); + await service.isAuthenticated(); + + // AsyncStorage.getItem should only be called once during first initialization + expect(AsyncStorage.getItem).toHaveBeenCalledTimes(1); + }); + }); + + /** + * Tests for getCurrentUser() + * + * Returns the full UserProfile object or null if no account exists. + * Used to display user info in the UI and for data submission. + */ + describe('getCurrentUser', () => { + /** + * Verifies null is returned when no profile has been created, + * allowing callers to distinguish between "no user" and "user exists". + */ + it('should return null when no profile exists', async () => { + const service = createAccountService(); + const result = await service.getCurrentUser(); + expect(result).toBeNull(); + }); + + /** + * Verifies that the full profile object is returned when it exists, + * with all fields intact from storage. + */ + it('should return the complete profile object when it exists', async () => { + const mockProfile = { + id: 'local_123', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockProfile)); + + const service = createAccountService(); + const result = await service.getCurrentUser(); + expect(result).toEqual(mockProfile); + }); + }); + + /** + * Tests for createAccount() + * + * Creates a new user profile with auto-generated ID and timestamps. + * The caller provides email, firstName, lastName; the service adds + * id, createdAt, and updatedAt automatically. + */ + describe('createAccount', () => { + /** + * Verifies that the service generates a unique local ID following + * the pattern: local_{timestamp}_{random} for offline identification. + */ + it('should generate a unique local ID with correct format', async () => { + const service = createAccountService(); + + const profile = await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + // ID format: local_{timestamp}_{random alphanumeric} + expect(profile.id).toMatch(/^local_\d+_[a-z0-9]+$/); + }); + + /** + * Verifies that provided profile data is preserved in the created account. + */ + it('should preserve provided profile data (email, firstName, lastName)', async () => { + const service = createAccountService(); + + const profile = await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + expect(profile.email).toBe('test@example.com'); + expect(profile.firstName).toBe('John'); + expect(profile.lastName).toBe('Doe'); + }); + + /** + * Verifies that createdAt and updatedAt timestamps are auto-generated + * and set to the same value on initial creation. + */ + it('should auto-generate createdAt and updatedAt timestamps', async () => { + const service = createAccountService(); + + const profile = await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + expect(profile.createdAt).toBeDefined(); + expect(profile.updatedAt).toBeDefined(); + // On creation, both timestamps should be identical + expect(profile.createdAt).toBe(profile.updatedAt); + }); + + /** + * Verifies that the new profile is persisted to AsyncStorage + * so it survives app restarts. + */ + it('should persist the new profile to AsyncStorage', async () => { + const service = createAccountService(); + + await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ACCOUNT_PROFILE, + expect.any(String) + ); + }); + + /** + * Verifies that after account creation, isAuthenticated() returns true. + * This ensures the in-memory state is updated, not just storage. + */ + it('should report authenticated immediately after account creation', async () => { + const service = createAccountService(); + + await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + const isAuth = await service.isAuthenticated(); + expect(isAuth).toBe(true); + }); + }); + + /** + * Tests for updateProfile() + * + * Allows partial updates to the user profile. Only provided fields + * are changed; others are preserved. Updates the updatedAt timestamp. + */ + describe('updateProfile', () => { + /** + * Verifies that attempting to update without an existing account + * throws an appropriate error message. + */ + it('should throw an error when no account exists', async () => { + const service = createAccountService(); + + await expect(service.updateProfile({ firstName: 'Jane' })).rejects.toThrow( + 'No account exists. Create an account first.' + ); + }); + + /** + * Verifies that partial updates only change specified fields + * while preserving other existing profile data. + */ + it('should apply partial updates while preserving other fields', async () => { + const service = createAccountService(); + + await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + // Clear mock to isolate the update call + (AsyncStorage.setItem as jest.Mock).mockClear(); + + const updated = await service.updateProfile({ firstName: 'Jane' }); + + // Updated field should change + expect(updated.firstName).toBe('Jane'); + // Other fields should remain unchanged + expect(updated.lastName).toBe('Doe'); + expect(updated.email).toBe('test@example.com'); + }); + + /** + * Verifies that updates are persisted to AsyncStorage. + */ + it('should persist updated profile to AsyncStorage', async () => { + const service = createAccountService(); + + await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + (AsyncStorage.setItem as jest.Mock).mockClear(); + + await service.updateProfile({ firstName: 'Jane' }); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ACCOUNT_PROFILE, + expect.any(String) + ); + }); + + /** + * Verifies that updatedAt timestamp is refreshed on each update, + * allowing tracking of when the profile was last modified. + */ + it('should update the updatedAt timestamp on each modification', async () => { + const service = createAccountService(); + + const original = await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + // Small delay to ensure different timestamp + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updated = await service.updateProfile({ firstName: 'Jane' }); + + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan( + new Date(original.updatedAt).getTime() + ); + }); + }); + + /** + * Tests for deleteAccount() + * + * Removes the user account from both memory and storage. + * Used for account deletion or app reset functionality. + */ + describe('deleteAccount', () => { + /** + * Verifies that the profile is removed from AsyncStorage. + */ + it('should remove the profile from AsyncStorage', async () => { + const service = createAccountService(); + + await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + await service.deleteAccount(); + + expect(AsyncStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEYS.ACCOUNT_PROFILE); + }); + + /** + * Verifies that after deletion, the service reports not authenticated. + * Note: We reset the mock to simulate actual cleared storage. + */ + it('should report not authenticated after account deletion', async () => { + const service = createAccountService(); + + await service.createAccount({ + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }); + + await service.deleteAccount(); + + // Simulate that storage is now empty + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + + const isAuth = await service.isAuthenticated(); + expect(isAuth).toBe(false); + }); + }); + + /** + * Tests for error handling + * + * Verifies graceful handling of AsyncStorage failures. + */ + describe('error handling', () => { + /** + * Verifies that storage read errors during initialization don't crash + * the app - instead, the service treats it as "no profile exists". + */ + it('should handle storage errors gracefully during initialization', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const service = createAccountService(); + const result = await service.isAuthenticated(); + + // Should complete without throwing, defaulting to not authenticated + expect(result).toBe(false); + }); + }); +}); diff --git a/homeflow/lib/services/__tests__/backend-factory.test.ts b/homeflow/lib/services/__tests__/backend-factory.test.ts new file mode 100644 index 0000000..de95e20 --- /dev/null +++ b/homeflow/lib/services/__tests__/backend-factory.test.ts @@ -0,0 +1,94 @@ +/** + * Unit Tests for Backend Factory + * + * The BackendFactory creates backend service instances based on configuration. + * Currently only supports 'local' (AsyncStorage) backend. When Firebase + * feature is added, this factory will be replaced with one that supports + * Firebase configuration. + * + * Key behaviors tested: + * - Creating LocalStorageBackend for 'local' type + * - Fallback behavior for unsupported backend types + * - Independent instance creation (not singleton) + */ + +import { BackendFactory } from '../backend-factory'; +import { LocalStorageBackend } from '../backends/local-storage'; + +/** + * FEATURE FLAGS + * + * Set these to true when the corresponding feature is implemented. + */ +const FEATURE_FLAGS = { + /** + * Set to true when Firebase backend is implemented. + * When true, the 'firebase' type test should expect FirebaseBackend. + */ + FIREBASE_BACKEND_IMPLEMENTED: false, +}; + +describe('BackendFactory', () => { + /** + * Tests for createBackend() + * + * Factory method that creates the appropriate backend service + * based on the provided configuration. + */ + describe('createBackend', () => { + /** + * Verifies that 'local' type creates a LocalStorageBackend instance. + * This is the default and currently only supported backend. + */ + it('should create LocalStorageBackend for "local" type', () => { + const backend = BackendFactory.createBackend({ type: 'local' }); + expect(backend).toBeInstanceOf(LocalStorageBackend); + }); + + /** + * Verifies graceful fallback to LocalStorageBackend when an + * unknown/unsupported backend type is requested. + * The factory logs a warning but doesn't throw. + */ + it('should fallback to LocalStorageBackend for unknown types', () => { + // @ts-expect-error - Intentionally testing with invalid type + const backend = BackendFactory.createBackend({ type: 'unknown' }); + expect(backend).toBeInstanceOf(LocalStorageBackend); + }); + + /** + * Tests 'firebase' backend type. + * + * When FIREBASE_BACKEND_IMPLEMENTED is false: expects fallback to LocalStorageBackend + * When FIREBASE_BACKEND_IMPLEMENTED is true: expects FirebaseBackend instance + * + * To enable: Set FEATURE_FLAGS.FIREBASE_BACKEND_IMPLEMENTED = true + * and import FirebaseBackend from '../backends/firebase' + */ + it('should handle "firebase" type based on implementation status', () => { + const backend = BackendFactory.createBackend({ type: 'firebase' }); + + if (FEATURE_FLAGS.FIREBASE_BACKEND_IMPLEMENTED) { + // TODO: When Firebase is implemented, update this assertion: + // expect(backend).toBeInstanceOf(FirebaseBackend); + throw new Error( + 'FIREBASE_BACKEND_IMPLEMENTED is true but FirebaseBackend assertion not updated. ' + + 'Import FirebaseBackend and update the expect() call.' + ); + } else { + // Firebase not implemented - should fallback to LocalStorageBackend + expect(backend).toBeInstanceOf(LocalStorageBackend); + } + }); + + /** + * Verifies that each call creates a new independent instance, + * not a singleton. This allows multiple backends if needed. + */ + it('should create independent instances on each call (not singleton)', () => { + const backend1 = BackendFactory.createBackend({ type: 'local' }); + const backend2 = BackendFactory.createBackend({ type: 'local' }); + expect(backend1).not.toBe(backend2); + }); + }); +}); diff --git a/homeflow/lib/services/__tests__/consent-service.test.ts b/homeflow/lib/services/__tests__/consent-service.test.ts new file mode 100644 index 0000000..f57a7d3 --- /dev/null +++ b/homeflow/lib/services/__tests__/consent-service.test.ts @@ -0,0 +1,357 @@ +/** + * Unit Tests for Consent Service + * + * The ConsentService manages informed consent for the research study. + * It tracks whether participants have consented, which version they + * consented to, and when. This is critical for IRB compliance. + * + * Key behaviors tested: + * - Consent status checking (hasConsented) + * - Consent version management (isConsentCurrent, needsReconsent) + * - Recording new consent with optional signature + * - Consent withdrawal for participant exit + * - Persistence across app sessions + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { STORAGE_KEYS, CONSENT_VERSION, STUDY_INFO } from '../../constants'; + +/** + * Helper to create a fresh ConsentService instance for each test. + * Resets the module cache to clear the singleton state. + */ +const createConsentService = () => { + jest.resetModules(); + const { ConsentService } = require('../consent-service'); + return ConsentService; +}; + +describe('ConsentService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: simulate empty storage (no prior consent) + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + }); + + /** + * Tests for hasConsented() + * + * Checks if the participant has given informed consent. + * Returns true only if consent was explicitly recorded. + */ + describe('hasConsented', () => { + /** + * Verifies that a new user (no stored consent) is correctly + * identified as not having consented. + */ + it('should return false when no consent record exists', async () => { + const service = createConsentService(); + const result = await service.hasConsented(); + expect(result).toBe(false); + }); + + /** + * Verifies that when valid consent data exists in storage + * (given=true, date present, version present), the service + * correctly reports consent as given. + */ + it('should return true when valid consent exists in storage', async () => { + // Simulate storage with all three required consent keys + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + if (key === STORAGE_KEYS.CONSENT_DATE) return Promise.resolve('2024-01-01T00:00:00.000Z'); + if (key === STORAGE_KEYS.CONSENT_VERSION) return Promise.resolve(CONSENT_VERSION); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const result = await service.hasConsented(); + expect(result).toBe(true); + }); + + /** + * Verifies that incomplete consent data (e.g., only CONSENT_GIVEN + * without the other required fields) does not count as valid consent. + */ + it('should return false when consent data is incomplete (missing date/version)', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + // Only CONSENT_GIVEN is set, missing date and version + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const result = await service.hasConsented(); + expect(result).toBe(false); + }); + }); + + /** + * Tests for isConsentCurrent() + * + * Checks if the participant's consent matches the current consent + * document version. If the consent form has been updated (new version), + * participants may need to re-consent. + */ + describe('isConsentCurrent', () => { + /** + * Verifies that without any consent, isConsentCurrent returns false. + */ + it('should return false when no consent exists', async () => { + const service = createConsentService(); + const result = await service.isConsentCurrent(); + expect(result).toBe(false); + }); + + /** + * Verifies that consent matching the current CONSENT_VERSION + * is considered current (no re-consent needed). + */ + it('should return true when consented version matches current version', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + if (key === STORAGE_KEYS.CONSENT_DATE) return Promise.resolve('2024-01-01T00:00:00.000Z'); + if (key === STORAGE_KEYS.CONSENT_VERSION) return Promise.resolve(CONSENT_VERSION); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const result = await service.isConsentCurrent(); + expect(result).toBe(true); + }); + + /** + * Verifies that consent from an older version is identified as + * not current, allowing the app to prompt for re-consent. + */ + it('should return false when consented version differs from current', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + if (key === STORAGE_KEYS.CONSENT_DATE) return Promise.resolve('2024-01-01T00:00:00.000Z'); + // Simulate an old consent version + if (key === STORAGE_KEYS.CONSENT_VERSION) return Promise.resolve('0.9.0'); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const result = await service.isConsentCurrent(); + expect(result).toBe(false); + }); + }); + + /** + * Tests for recordConsent() + * + * Records that the participant has given informed consent. + * Stores the consent status, timestamp, and current version. + * Optionally accepts a signature (typed name). + */ + describe('recordConsent', () => { + /** + * Verifies that recording consent persists all three required + * keys to AsyncStorage: given flag, date, and version. + */ + it('should store all consent data to AsyncStorage', async () => { + const service = createConsentService(); + await service.recordConsent(); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEYS.CONSENT_GIVEN, 'true'); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.CONSENT_DATE, + expect.any(String) + ); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.CONSENT_VERSION, + CONSENT_VERSION + ); + }); + + /** + * Verifies that a signature can be provided and is stored + * in the consent record for IRB documentation purposes. + */ + it('should store participant signature when provided', async () => { + const service = createConsentService(); + await service.recordConsent('John Doe'); + + const record = await service.getConsentRecord(); + expect(record?.participantSignature).toBe('John Doe'); + }); + + /** + * Verifies that after recording consent, hasConsented() returns true. + */ + it('should report as consented immediately after recording', async () => { + const service = createConsentService(); + await service.recordConsent(); + + const hasConsented = await service.hasConsented(); + expect(hasConsented).toBe(true); + }); + }); + + /** + * Tests for withdrawConsent() + * + * Removes all consent records. Used when a participant chooses + * to withdraw from the study per IRB requirements. + */ + describe('withdrawConsent', () => { + /** + * Verifies that withdrawal removes all three consent keys + * from AsyncStorage in a single atomic operation. + */ + it('should remove all consent keys from storage using multiRemove', async () => { + const service = createConsentService(); + await service.recordConsent(); + await service.withdrawConsent(); + + expect(AsyncStorage.multiRemove).toHaveBeenCalledWith([ + STORAGE_KEYS.CONSENT_GIVEN, + STORAGE_KEYS.CONSENT_DATE, + STORAGE_KEYS.CONSENT_VERSION, + ]); + }); + + /** + * Verifies that after withdrawal, hasConsented() returns false. + */ + it('should report as not consented after withdrawal', async () => { + const service = createConsentService(); + await service.recordConsent(); + await service.withdrawConsent(); + + const hasConsented = await service.hasConsented(); + expect(hasConsented).toBe(false); + }); + }); + + /** + * Tests for getConsentRecord() + * + * Returns the full consent record object containing all details + * about when and how consent was given. + */ + describe('getConsentRecord', () => { + /** + * Verifies null is returned when no consent has been recorded. + */ + it('should return null when no consent exists', async () => { + const service = createConsentService(); + const record = await service.getConsentRecord(); + expect(record).toBeNull(); + }); + + /** + * Verifies that the consent record includes study metadata + * (study name, IRB protocol) from the constants. + */ + it('should return consent record with study info when consent exists', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + if (key === STORAGE_KEYS.CONSENT_DATE) return Promise.resolve('2024-01-01T00:00:00.000Z'); + if (key === STORAGE_KEYS.CONSENT_VERSION) return Promise.resolve(CONSENT_VERSION); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const record = await service.getConsentRecord(); + + expect(record).toEqual({ + given: true, + version: CONSENT_VERSION, + timestamp: '2024-01-01T00:00:00.000Z', + studyName: STUDY_INFO.name, + irbProtocol: STUDY_INFO.irbProtocol, + }); + }); + }); + + /** + * Tests for getCurrentVersion() + * + * Returns the current consent document version constant. + * Synchronous method - doesn't require storage access. + */ + describe('getCurrentVersion', () => { + /** + * Verifies the method returns the CONSENT_VERSION constant. + */ + it('should return the current consent version constant', () => { + const service = createConsentService(); + expect(service.getCurrentVersion()).toBe(CONSENT_VERSION); + }); + }); + + /** + * Tests for needsReconsent() + * + * Determines if a participant who previously consented needs to + * re-consent due to an updated consent document. + */ + describe('needsReconsent', () => { + /** + * Verifies that without prior consent, re-consent is not needed + * (they need initial consent instead). + */ + it('should return false when no consent exists', async () => { + const service = createConsentService(); + const result = await service.needsReconsent(); + expect(result).toBe(false); + }); + + /** + * Verifies that current consent does not trigger re-consent. + */ + it('should return false when consent version is current', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + if (key === STORAGE_KEYS.CONSENT_DATE) return Promise.resolve('2024-01-01T00:00:00.000Z'); + if (key === STORAGE_KEYS.CONSENT_VERSION) return Promise.resolve(CONSENT_VERSION); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const result = await service.needsReconsent(); + expect(result).toBe(false); + }); + + /** + * Verifies that outdated consent version triggers re-consent requirement. + */ + it('should return true when consent version is outdated', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.CONSENT_GIVEN) return Promise.resolve('true'); + if (key === STORAGE_KEYS.CONSENT_DATE) return Promise.resolve('2024-01-01T00:00:00.000Z'); + // Old version that doesn't match current CONSENT_VERSION + if (key === STORAGE_KEYS.CONSENT_VERSION) return Promise.resolve('0.9.0'); + return Promise.resolve(null); + }); + + const service = createConsentService(); + const result = await service.needsReconsent(); + expect(result).toBe(true); + }); + }); + + /** + * Tests for error handling + * + * Verifies graceful handling of AsyncStorage failures. + */ + describe('error handling', () => { + /** + * Verifies that storage errors during initialization don't crash + * the service - it defaults to "no consent" state. + */ + it('should handle storage errors gracefully during initialization', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const service = createConsentService(); + const result = await service.hasConsented(); + + // Should complete without throwing, defaulting to no consent + expect(result).toBe(false); + }); + }); +}); diff --git a/homeflow/lib/services/__tests__/jest.config.js b/homeflow/lib/services/__tests__/jest.config.js new file mode 100644 index 0000000..58dcd7c --- /dev/null +++ b/homeflow/lib/services/__tests__/jest.config.js @@ -0,0 +1,34 @@ +/** + * Jest configuration for lib/services unit tests + * + * Uses ts-jest for TypeScript support without needing the full + * react-native preset since these are pure service tests. + */ + +module.exports = { + rootDir: '../../../', + testMatch: ['/lib/services/__tests__/**/*.test.ts'], + setupFilesAfterEnv: ['/lib/services/__tests__/jest.setup.js'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: { + module: 'commonjs', + esModuleInterop: true, + allowSyntheticDefaultImports: true, + strict: true, + skipLibCheck: true, + moduleResolution: 'node', + }, + }], + }, + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + testEnvironment: 'node', + collectCoverageFrom: [ + 'lib/services/**/*.ts', + '!lib/services/**/*.test.ts', + '!lib/services/__tests__/**', + ], +}; diff --git a/homeflow/lib/services/__tests__/jest.setup.js b/homeflow/lib/services/__tests__/jest.setup.js new file mode 100644 index 0000000..e13c6bb --- /dev/null +++ b/homeflow/lib/services/__tests__/jest.setup.js @@ -0,0 +1,39 @@ +/** + * Jest Setup for Service Tests + * + * This file runs before each test file and sets up global mocks. + */ + +// Create mock functions that can be reset +const mockAsyncStorage = { + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), + multiRemove: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), +}; + +// Mock AsyncStorage - this mock persists across jest.resetModules() +jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage); + +// Reset mocks before each test +beforeEach(() => { + mockAsyncStorage.setItem.mockClear(); + mockAsyncStorage.setItem.mockImplementation(() => Promise.resolve()); + mockAsyncStorage.getItem.mockClear(); + mockAsyncStorage.getItem.mockImplementation(() => Promise.resolve(null)); + mockAsyncStorage.removeItem.mockClear(); + mockAsyncStorage.removeItem.mockImplementation(() => Promise.resolve()); + mockAsyncStorage.multiRemove.mockClear(); + mockAsyncStorage.multiRemove.mockImplementation(() => Promise.resolve()); + mockAsyncStorage.clear.mockClear(); + mockAsyncStorage.getAllKeys.mockClear(); +}); + +// Silence console warnings in tests +global.console = { + ...console, + warn: jest.fn(), + error: jest.fn(), +}; diff --git a/homeflow/lib/services/__tests__/local-storage.test.ts b/homeflow/lib/services/__tests__/local-storage.test.ts new file mode 100644 index 0000000..021a575 --- /dev/null +++ b/homeflow/lib/services/__tests__/local-storage.test.ts @@ -0,0 +1,444 @@ +/** + * Unit Tests for LocalStorageBackend + * + * The LocalStorageBackend implements the BackendService interface using + * AsyncStorage for persistence. It provides CRUD operations for tasks, + * outcomes, and questionnaire responses. + * + * This is the default backend for the MVP. When Firebase is added, + * a FirebaseBackend will provide the same interface with remote sync. + * + * Key behaviors tested: + * - Scheduler state persistence (load/save) + * - Task CRUD operations + * - Outcome recording + * - Questionnaire response storage + * - Error handling for storage failures + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { LocalStorageBackend } from '../backends/local-storage'; + +describe('LocalStorageBackend', () => { + let backend: LocalStorageBackend; + + beforeEach(() => { + jest.clearAllMocks(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + backend = new LocalStorageBackend(); + }); + + /** + * Tests for initialize() + * + * No-op for LocalStorageBackend since AsyncStorage is always available. + * Included for interface compatibility with remote backends. + */ + describe('initialize', () => { + /** + * Verifies initialize completes without error. + */ + it('should complete without error (no-op for local storage)', async () => { + await expect(backend.initialize()).resolves.toBeUndefined(); + }); + }); + + /** + * Tests for setUserId() + * + * No-op for LocalStorageBackend since local storage doesn't need + * user scoping. Remote backends would use this to scope data. + */ + describe('setUserId', () => { + /** + * Verifies setUserId is a no-op that doesn't throw. + */ + it('should be a no-op that does not throw', () => { + expect(() => backend.setUserId('user-123')).not.toThrow(); + expect(() => backend.setUserId(null)).not.toThrow(); + }); + }); + + /** + * Tests for Scheduler State operations + * + * The scheduler state contains all tasks and their completion outcomes. + */ + describe('Scheduler State', () => { + describe('loadSchedulerState', () => { + /** + * Verifies null returned when no state has been saved. + */ + it('should return null when no state exists in storage', async () => { + const state = await backend.loadSchedulerState(); + expect(state).toBeNull(); + }); + + /** + * Verifies state is correctly parsed from storage, including + * date deserialization for outcome timestamps. + */ + it('should parse and return state from storage with deserialized dates', async () => { + const mockState = { + tasks: [{ id: 'task-1', title: 'Test Task' }], + outcomes: [{ id: 'outcome-1', completedAt: '2024-01-01T00:00:00.000Z' }], + }; + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockState)); + + const state = await backend.loadSchedulerState(); + + expect(state?.tasks).toHaveLength(1); + expect(state?.outcomes).toHaveLength(1); + // Verify date was deserialized from ISO string to Date object + expect(state?.outcomes[0].completedAt).toBeInstanceOf(Date); + }); + + /** + * Verifies graceful error handling returns null instead of crashing. + */ + it('should return null on storage read error', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const state = await backend.loadSchedulerState(); + expect(state).toBeNull(); + }); + }); + + describe('saveSchedulerState', () => { + /** + * Verifies state is serialized and saved to correct storage key. + */ + it('should persist state to AsyncStorage', async () => { + const state = { + tasks: [{ id: 'task-1' }], + outcomes: [{ id: 'outcome-1', completedAt: new Date() }], + }; + + await backend.saveSchedulerState(state); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + '@scheduler_state', + expect.any(String) + ); + }); + + /** + * Verifies storage errors are propagated to caller. + */ + it('should throw on storage write error', async () => { + (AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const state = { tasks: [], outcomes: [] }; + await expect(backend.saveSchedulerState(state)).rejects.toThrow('Storage error'); + }); + }); + }); + + /** + * Tests for Task CRUD operations + */ + describe('Task Operations', () => { + describe('createTask', () => { + /** + * Verifies new task is added to state and persisted. + */ + it('should add task to state and save', async () => { + const task = { id: 'new-task', title: 'New Task' }; + const result = await backend.createTask(task); + + expect(result).toEqual(task); + expect(AsyncStorage.setItem).toHaveBeenCalled(); + }); + + /** + * Verifies task is appended to existing tasks, not replacing them. + */ + it('should append to existing tasks (not replace)', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ + tasks: [{ id: 'existing-task' }], + outcomes: [], + }) + ); + + await backend.createTask({ id: 'new-task' }); + + const setItemCall = (AsyncStorage.setItem as jest.Mock).mock.calls[0]; + const savedState = JSON.parse(setItemCall[1]); + expect(savedState.tasks).toHaveLength(2); + }); + }); + + describe('updateTask', () => { + /** + * Verifies existing task is updated in place. + */ + it('should update existing task by ID', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ + tasks: [{ id: 'task-1', title: 'Original' }], + outcomes: [], + }) + ); + + await backend.updateTask({ id: 'task-1', title: 'Updated' }); + + const setItemCall = (AsyncStorage.setItem as jest.Mock).mock.calls[0]; + const savedState = JSON.parse(setItemCall[1]); + expect(savedState.tasks[0].title).toBe('Updated'); + }); + }); + + describe('deleteTask', () => { + /** + * Verifies task is removed from state. + */ + it('should remove task by ID', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ + tasks: [{ id: 'task-1' }, { id: 'task-2' }], + outcomes: [], + }) + ); + + await backend.deleteTask('task-1'); + + const setItemCall = (AsyncStorage.setItem as jest.Mock).mock.calls[0]; + const savedState = JSON.parse(setItemCall[1]); + expect(savedState.tasks).toHaveLength(1); + expect(savedState.tasks[0].id).toBe('task-2'); + }); + + /** + * Verifies outcomes associated with deleted task are also removed. + * Outcome IDs are prefixed with task ID (e.g., "task-1_2024-01-01"). + */ + it('should remove outcomes associated with deleted task', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ + tasks: [{ id: 'task-1' }], + outcomes: [ + { id: 'task-1_2024-01-01', completedAt: '2024-01-01T00:00:00.000Z' }, + { id: 'task-2_2024-01-01', completedAt: '2024-01-01T00:00:00.000Z' }, + ], + }) + ); + + await backend.deleteTask('task-1'); + + const setItemCall = (AsyncStorage.setItem as jest.Mock).mock.calls[0]; + const savedState = JSON.parse(setItemCall[1]); + // Only task-2's outcome should remain + expect(savedState.outcomes).toHaveLength(1); + expect(savedState.outcomes[0].id).toBe('task-2_2024-01-01'); + }); + }); + + describe('getTasks', () => { + /** + * Verifies empty array when no tasks exist. + */ + it('should return empty array when no state exists', async () => { + const tasks = await backend.getTasks(); + expect(tasks).toEqual([]); + }); + + /** + * Verifies all tasks are returned. + */ + it('should return all tasks from state', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ + tasks: [{ id: 'task-1' }, { id: 'task-2' }], + outcomes: [], + }) + ); + + const tasks = await backend.getTasks(); + expect(tasks).toHaveLength(2); + }); + }); + }); + + /** + * Tests for Outcome operations + * + * Outcomes record task completion events with timestamps. + */ + describe('Outcome Operations', () => { + describe('createOutcome', () => { + /** + * Verifies outcome is added to state and persisted. + */ + it('should add outcome to state and save', async () => { + const outcome = { id: 'outcome-1', completedAt: new Date() }; + const result = await backend.createOutcome(outcome); + + expect(result).toEqual(outcome); + expect(AsyncStorage.setItem).toHaveBeenCalled(); + }); + }); + + describe('getOutcomes', () => { + /** + * Verifies empty array when no outcomes exist. + */ + it('should return empty array when no state exists', async () => { + const outcomes = await backend.getOutcomes(); + expect(outcomes).toEqual([]); + }); + + /** + * Verifies outcomes are returned with deserialized dates. + */ + it('should return outcomes with deserialized completedAt dates', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ + tasks: [], + outcomes: [{ id: 'outcome-1', completedAt: '2024-01-01T00:00:00.000Z' }], + }) + ); + + const outcomes = await backend.getOutcomes(); + expect(outcomes).toHaveLength(1); + expect(outcomes[0].completedAt).toBeInstanceOf(Date); + }); + }); + }); + + /** + * Tests for Questionnaire Response operations + * + * Stores FHIR-compatible questionnaire responses separately + * from scheduler state. + */ + describe('Questionnaire Operations', () => { + describe('saveQuestionnaireResponse', () => { + /** + * Verifies response is saved to dedicated storage key. + */ + it('should save response to questionnaire storage', async () => { + const response = { id: 'response-1', answers: {} }; + await backend.saveQuestionnaireResponse(response); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + '@questionnaire_responses', + expect.stringContaining('response-1') + ); + }); + + /** + * Verifies new responses are appended to existing ones. + */ + it('should append to existing responses (not replace)', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === '@questionnaire_responses') { + return Promise.resolve(JSON.stringify([{ id: 'existing' }])); + } + return Promise.resolve(null); + }); + + await backend.saveQuestionnaireResponse({ id: 'new' }); + + const setItemCall = (AsyncStorage.setItem as jest.Mock).mock.calls[0]; + const saved = JSON.parse(setItemCall[1]); + expect(saved).toHaveLength(2); + }); + + /** + * Verifies storage errors are propagated. + */ + it('should throw on storage error', async () => { + (AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + await expect(backend.saveQuestionnaireResponse({ id: 'test' })).rejects.toThrow(); + }); + }); + + describe('getQuestionnaireResponses', () => { + /** + * Verifies empty array when no responses exist. + */ + it('should return empty array when no responses exist', async () => { + const responses = await backend.getQuestionnaireResponses(); + expect(responses).toEqual([]); + }); + + /** + * Verifies all responses returned with deserialized dates. + */ + it('should return all responses with deserialized completedAt dates', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === '@questionnaire_responses') { + return Promise.resolve( + JSON.stringify([ + { id: 'r1', completedAt: '2024-01-01T00:00:00.000Z' }, + { id: 'r2', completedAt: '2024-01-02T00:00:00.000Z' }, + ]) + ); + } + return Promise.resolve(null); + }); + + const responses = await backend.getQuestionnaireResponses(); + expect(responses).toHaveLength(2); + expect(responses[0].completedAt).toBeInstanceOf(Date); + }); + + /** + * Verifies filtering by taskId when provided. + * Used to get responses for a specific scheduled task. + */ + it('should filter responses by taskId when provided', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === '@questionnaire_responses') { + return Promise.resolve( + JSON.stringify([ + { id: 'r1', metadata: { taskId: 'task-1' } }, + { id: 'r2', metadata: { taskId: 'task-2' } }, + { id: 'r3', metadata: { taskId: 'task-1' } }, + ]) + ); + } + return Promise.resolve(null); + }); + + const responses = await backend.getQuestionnaireResponses('task-1'); + expect(responses).toHaveLength(2); + expect(responses.every((r) => r.metadata?.taskId === 'task-1')).toBe(true); + }); + + /** + * Verifies graceful error handling returns empty array. + */ + it('should return empty array on storage error', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const responses = await backend.getQuestionnaireResponses(); + expect(responses).toEqual([]); + }); + }); + }); + + /** + * Tests for Sync operations + * + * No-ops for LocalStorageBackend since there's no remote to sync with. + * Remote backends would implement actual sync logic. + */ + describe('Sync Operations', () => { + /** + * Verifies syncToRemote is a no-op that completes without error. + */ + it('syncToRemote should complete without error (no-op)', async () => { + await expect(backend.syncToRemote()).resolves.toBeUndefined(); + }); + + /** + * Verifies syncFromRemote is a no-op that completes without error. + */ + it('syncFromRemote should complete without error (no-op)', async () => { + await expect(backend.syncFromRemote()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/homeflow/lib/services/__tests__/onboarding-service.test.ts b/homeflow/lib/services/__tests__/onboarding-service.test.ts new file mode 100644 index 0000000..c22f485 --- /dev/null +++ b/homeflow/lib/services/__tests__/onboarding-service.test.ts @@ -0,0 +1,587 @@ +/** + * Unit Tests for Onboarding Service + * + * The OnboardingService is a state machine that manages the multi-step + * enrollment flow for the research study. It tracks which step the user + * is on, stores collected data, and persists state so users can resume. + * + * Onboarding steps: WELCOME → CHAT → CONSENT → PERMISSIONS → BASELINE_SURVEY → COMPLETE + * + * Key behaviors tested: + * - State machine navigation (start, nextStep, goToStep, complete) + * - Data collection and merging (updateData, getData) + * - Progress tracking (getProgress, isComplete) + * - State persistence and recovery + * - Reset functionality for re-enrollment + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { STORAGE_KEYS, OnboardingStep, ONBOARDING_FLOW } from '../../constants'; + +/** + * Helper to create a fresh OnboardingService instance for each test. + * Resets the module cache to clear the singleton state. + */ +const createOnboardingService = () => { + jest.resetModules(); + const { OnboardingService } = require('../onboarding-service'); + return OnboardingService; +}; + +describe('OnboardingService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: simulate empty storage (no prior onboarding) + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + }); + + /** + * Tests for hasStarted() + * + * Determines if the user has begun the onboarding process. + * Used to decide whether to show welcome screen vs resume. + */ + describe('hasStarted', () => { + /** + * Verifies that a fresh app state (no stored step) correctly + * reports that onboarding has not started. + */ + it('should return false when onboarding has never been started', async () => { + const service = createOnboardingService(); + const result = await service.hasStarted(); + expect(result).toBe(false); + }); + + /** + * Verifies that when a step exists in storage, the service + * correctly identifies that onboarding has started. + */ + it('should return true when onboarding step exists in storage', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.WELCOME); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const result = await service.hasStarted(); + expect(result).toBe(true); + }); + }); + + /** + * Tests for getCurrentStep() + * + * Returns the current step in the onboarding flow. + * Used by navigation to route to the correct screen. + */ + describe('getCurrentStep', () => { + /** + * Verifies null is returned when onboarding hasn't started, + * allowing callers to distinguish from valid step states. + */ + it('should return null when onboarding has not started', async () => { + const service = createOnboardingService(); + const result = await service.getCurrentStep(); + expect(result).toBeNull(); + }); + + /** + * Verifies the correct step is loaded from storage. + */ + it('should return the step stored in AsyncStorage', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.CONSENT); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const result = await service.getCurrentStep(); + expect(result).toBe(OnboardingStep.CONSENT); + }); + }); + + /** + * Tests for isComplete() + * + * Checks if the user has finished all onboarding steps. + * Used by auth guards to allow access to the main app. + */ + describe('isComplete', () => { + /** + * Verifies false when onboarding hasn't started. + */ + it('should return false when onboarding has not started', async () => { + const service = createOnboardingService(); + const result = await service.isComplete(); + expect(result).toBe(false); + }); + + /** + * Verifies false when on an intermediate step (not COMPLETE). + */ + it('should return false when on an intermediate step', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.CONSENT); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const result = await service.isComplete(); + expect(result).toBe(false); + }); + + /** + * Verifies true when the current step is COMPLETE. + */ + it('should return true when step is COMPLETE', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.COMPLETE); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const result = await service.isComplete(); + expect(result).toBe(true); + }); + }); + + /** + * Tests for start() + * + * Initializes the onboarding flow from the beginning. + * Sets the step to WELCOME and initializes empty data. + */ + describe('start', () => { + /** + * Verifies that start() sets the step to WELCOME. + */ + it('should set initial step to WELCOME', async () => { + const service = createOnboardingService(); + await service.start(); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ONBOARDING_STEP, + OnboardingStep.WELCOME + ); + }); + + /** + * Verifies that start() initializes empty onboarding data. + */ + it('should initialize with empty data object', async () => { + const service = createOnboardingService(); + await service.start(); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEYS.ONBOARDING_DATA, '{}'); + }); + + /** + * Verifies hasStarted() returns true after calling start(). + */ + it('should mark onboarding as started', async () => { + const service = createOnboardingService(); + await service.start(); + + const hasStarted = await service.hasStarted(); + expect(hasStarted).toBe(true); + }); + }); + + /** + * Tests for nextStep() + * + * Advances to the next step in the ONBOARDING_FLOW array. + * Auto-starts if not already started. + */ + describe('nextStep', () => { + /** + * Verifies that calling nextStep() without starting first + * automatically starts onboarding and returns WELCOME. + */ + it('should auto-start onboarding if not started and return WELCOME', async () => { + const service = createOnboardingService(); + const step = await service.nextStep(); + + expect(step).toBe(OnboardingStep.WELCOME); + }); + + /** + * Verifies correct progression through the flow: + * WELCOME → CHAT + */ + it('should advance from WELCOME to CHAT', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.WELCOME); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const step = await service.nextStep(); + + expect(step).toBe(OnboardingStep.CHAT); + }); + + /** + * Verifies that calling nextStep() when already on COMPLETE + * stays on COMPLETE (doesn't go out of bounds). + */ + it('should stay on COMPLETE when already complete (boundary check)', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.COMPLETE); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const step = await service.nextStep(); + + expect(step).toBe(OnboardingStep.COMPLETE); + }); + + /** + * Verifies that the new step is persisted to storage. + */ + it('should persist the new step to AsyncStorage', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.WELCOME); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + await service.nextStep(); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ONBOARDING_STEP, + OnboardingStep.CHAT + ); + }); + }); + + /** + * Tests for goToStep() + * + * Allows jumping to a specific step (e.g., for deep linking + * or going back to fix earlier responses). + */ + describe('goToStep', () => { + /** + * Verifies that a specific step can be set directly. + */ + it('should set the specified step', async () => { + const service = createOnboardingService(); + await service.goToStep(OnboardingStep.PERMISSIONS); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ONBOARDING_STEP, + OnboardingStep.PERMISSIONS + ); + }); + + /** + * Verifies auto-start behavior when going to a step + * before onboarding has been initialized. + */ + it('should auto-start onboarding if not already started', async () => { + const service = createOnboardingService(); + await service.goToStep(OnboardingStep.CONSENT); + + // Should have been started (check that setItem was called for step) + const stepCalls = (AsyncStorage.setItem as jest.Mock).mock.calls.filter( + (call) => call[0] === STORAGE_KEYS.ONBOARDING_STEP + ); + expect(stepCalls.length).toBeGreaterThanOrEqual(1); + }); + }); + + /** + * Tests for updateData() + * + * Merges new data into the onboarding data object. + * Used to save responses from each step (eligibility, account, etc.). + */ + describe('updateData', () => { + /** + * Verifies that data is merged into the existing state + * (not replaced entirely). + */ + it('should merge new data into existing state', async () => { + const service = createOnboardingService(); + await service.start(); + + await service.updateData({ + eligibility: { + hasIPhone: true, + hasAppleWatch: true, + hasBPHDiagnosis: true, + consideringSurgery: true, + willingToUseThrone: true, + isEligible: true, + }, + }); + + const data = await service.getData(); + expect(data.eligibility?.hasIPhone).toBe(true); + }); + + /** + * Verifies auto-start when updating data before onboarding started. + */ + it('should auto-start onboarding if not started when updating data', async () => { + const service = createOnboardingService(); + + await service.updateData({ + account: { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }, + }); + + const hasStarted = await service.hasStarted(); + expect(hasStarted).toBe(true); + }); + + /** + * Verifies that updated data is persisted to storage. + */ + it('should persist updated data to AsyncStorage', async () => { + const service = createOnboardingService(); + await service.start(); + (AsyncStorage.setItem as jest.Mock).mockClear(); + + await service.updateData({ + account: { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }, + }); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ONBOARDING_DATA, + expect.stringContaining('John') + ); + }); + }); + + /** + * Tests for getData() + * + * Retrieves all collected onboarding data. + */ + describe('getData', () => { + /** + * Verifies empty object returned when not started. + */ + it('should return empty object when onboarding not started', async () => { + const service = createOnboardingService(); + const data = await service.getData(); + expect(data).toEqual({}); + }); + + /** + * Verifies stored data is returned correctly. + */ + it('should return data loaded from storage', async () => { + const mockData = { + account: { firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, + }; + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.CONSENT); + if (key === STORAGE_KEYS.ONBOARDING_DATA) return Promise.resolve(JSON.stringify(mockData)); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + const data = await service.getData(); + expect(data).toEqual(mockData); + }); + }); + + /** + * Tests for markIneligible() + * + * Sets eligibility.isEligible to false when the user fails + * eligibility screening. Used to track ineligible participants. + */ + describe('markIneligible', () => { + /** + * Verifies that markIneligible sets the isEligible flag to false. + */ + it('should set eligibility.isEligible to false', async () => { + const service = createOnboardingService(); + await service.start(); + await service.markIneligible(); + + const data = await service.getData(); + expect(data.eligibility?.isEligible).toBe(false); + }); + }); + + /** + * Tests for complete() + * + * Marks onboarding as finished by setting step to COMPLETE. + */ + describe('complete', () => { + /** + * Verifies that complete() sets the step to COMPLETE. + */ + it('should set step to COMPLETE', async () => { + const service = createOnboardingService(); + await service.start(); + await service.complete(); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.ONBOARDING_STEP, + OnboardingStep.COMPLETE + ); + }); + + /** + * Verifies isComplete() returns true after calling complete(). + */ + it('should report isComplete as true after completion', async () => { + const service = createOnboardingService(); + await service.start(); + await service.complete(); + + const isComplete = await service.isComplete(); + expect(isComplete).toBe(true); + }); + }); + + /** + * Tests for reset() + * + * Clears all onboarding state for re-enrollment or testing. + * Removes step, data, and related keys (consent, permissions, etc.). + */ + describe('reset', () => { + /** + * Verifies that reset() removes all onboarding-related keys + * from AsyncStorage in a single multiRemove call. + */ + it('should remove all onboarding-related keys from storage', async () => { + const service = createOnboardingService(); + await service.start(); + await service.reset(); + + expect(AsyncStorage.multiRemove).toHaveBeenCalledWith([ + STORAGE_KEYS.ONBOARDING_STEP, + STORAGE_KEYS.ONBOARDING_DATA, + STORAGE_KEYS.CONSENT_GIVEN, + STORAGE_KEYS.CONSENT_DATE, + STORAGE_KEYS.CONSENT_VERSION, + STORAGE_KEYS.MEDICAL_HISTORY, + STORAGE_KEYS.ELIGIBILITY_RESPONSES, + STORAGE_KEYS.IPSS_BASELINE, + STORAGE_KEYS.PERMISSIONS_STATUS, + ]); + }); + + /** + * Verifies hasStarted() returns false after reset. + */ + it('should report as not started after reset', async () => { + const service = createOnboardingService(); + await service.start(); + await service.reset(); + + // Simulate cleared storage + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + + const hasStarted = await service.hasStarted(); + expect(hasStarted).toBe(false); + }); + }); + + /** + * Tests for getNextStepName() + * + * Returns the next step in the flow without advancing. + * Useful for showing "Next: ..." previews in the UI. + * Note: This is a synchronous method that reads from in-memory state. + */ + describe('getNextStepName', () => { + /** + * Verifies WELCOME is returned when state is null (not started). + */ + it('should return WELCOME when onboarding not started', () => { + const service = createOnboardingService(); + const next = service.getNextStepName(); + expect(next).toBe(OnboardingStep.WELCOME); + }); + }); + + /** + * Tests for getProgress() + * + * Returns the completion percentage (0-100) based on current step. + * Note: This is a synchronous method that reads from in-memory state. + */ + describe('getProgress', () => { + /** + * Verifies 0% progress when not started. + */ + it('should return 0 when onboarding not started', () => { + const service = createOnboardingService(); + const progress = service.getProgress(); + expect(progress).toBe(0); + }); + + /** + * Verifies correct percentage calculation for intermediate step. + */ + it('should return correct percentage for current step', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.CONSENT); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + // Must initialize to load state from storage + await service.initialize(); + + const progress = service.getProgress(); + // CONSENT is index 2 in a 6-step flow (indices 0-5) + // Progress = (2 / 5) * 100 = 40% + const expectedIndex = ONBOARDING_FLOW.indexOf(OnboardingStep.CONSENT); + const expectedProgress = Math.round((expectedIndex / (ONBOARDING_FLOW.length - 1)) * 100); + expect(progress).toBe(expectedProgress); + }); + + /** + * Verifies 100% progress when complete. + */ + it('should return 100 when on COMPLETE step', async () => { + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.COMPLETE); + return Promise.resolve(null); + }); + + const service = createOnboardingService(); + await service.initialize(); + + const progress = service.getProgress(); + expect(progress).toBe(100); + }); + }); + + /** + * Tests for error handling + */ + describe('error handling', () => { + /** + * Verifies graceful handling of storage errors during initialization. + */ + it('should handle storage errors gracefully during initialization', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const service = createOnboardingService(); + const step = await service.getCurrentStep(); + + // Should complete without throwing, returning null + expect(step).toBeNull(); + }); + }); +}); diff --git a/homeflow/lib/services/__tests__/throne-service.test.ts b/homeflow/lib/services/__tests__/throne-service.test.ts new file mode 100644 index 0000000..158ee4c --- /dev/null +++ b/homeflow/lib/services/__tests__/throne-service.test.ts @@ -0,0 +1,351 @@ +/** + * Unit Tests for Throne Service (Stubbed Implementation) + * + * The ThroneService provides integration with Throne uroflowmetry devices. + * This is a STUB implementation for the MVP - it simulates the OAuth flow + * and device connection without actual hardware/API integration. + * + * Note: These tests verify the stub behavior. When real Throne API + * integration is added, these tests should be updated or extended. + * + * Key behaviors tested: + * - Permission status tracking (not_determined → granted/skipped) + * - Simulated OAuth permission request flow + * - Connection status based on permission state + * - Empty data returns (stub returns no mock measurements) + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { STORAGE_KEYS } from '../../constants'; + +/** + * FEATURE FLAGS + * + * Set these to true when the corresponding feature is implemented. + * Tests marked with these flags verify stub behavior and should be + * updated when real implementation is added. + */ +const FEATURE_FLAGS = { + /** + * Set to true when real Throne API integration is implemented. + * When true, tests should expect actual OAuth flow and real data. + */ + THRONE_API_IMPLEMENTED: false, +}; + +/** + * Helper to create fresh ThroneService instance for each test. + */ +const createThroneService = () => { + jest.resetModules(); + const { ThroneService, isThroneAvailable } = require('../throne-service'); + return { ThroneService, isThroneAvailable }; +}; + +describe('ThroneService', () => { + beforeEach(() => { + jest.clearAllMocks(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + // Use fake timers to control the simulated API delay + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Tests for getPermissionStatus() + * + * Returns the current Throne permission status: + * - 'not_determined': User hasn't been asked yet + * - 'granted': User authorized Throne access + * - 'denied': User rejected authorization + * - 'skipped': User chose to skip setup (can connect later) + */ + describe('getPermissionStatus', () => { + /** + * Verifies default state is 'not_determined' for new users. + */ + it('should return "not_determined" when no permission has been set', async () => { + const { ThroneService } = createThroneService(); + const status = await ThroneService.getPermissionStatus(); + expect(status).toBe('not_determined'); + }); + + /** + * Verifies granted status is loaded from storage. + */ + it('should return "granted" when permission was previously granted', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'granted' }) + ); + + const { ThroneService } = createThroneService(); + const status = await ThroneService.getPermissionStatus(); + expect(status).toBe('granted'); + }); + + /** + * Verifies skipped status is loaded from storage. + */ + it('should return "skipped" when user previously skipped setup', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'skipped' }) + ); + + const { ThroneService } = createThroneService(); + const status = await ThroneService.getPermissionStatus(); + expect(status).toBe('skipped'); + }); + }); + + /** + * Tests for requestPermission() + * + * STUB BEHAVIOR: Simulates OAuth flow with 1-second delay, + * then always returns 'granted'. In production, this would + * open Throne's OAuth authorization page. + */ + describe('requestPermission', () => { + /** + * Verifies stub always grants permission after simulated delay. + * This tests the expected stub behavior, not real OAuth. + */ + it('should return "granted" after simulated delay (stub behavior)', async () => { + const { ThroneService } = createThroneService(); + + const permissionPromise = ThroneService.requestPermission(); + + // Advance past the 1-second simulated OAuth delay and flush promises + await jest.advanceTimersByTimeAsync(1000); + + const status = await permissionPromise; + expect(status).toBe('granted'); + }); + + /** + * Verifies permission status is persisted to AsyncStorage. + */ + it('should persist granted permission to AsyncStorage', async () => { + const { ThroneService } = createThroneService(); + + const permissionPromise = ThroneService.requestPermission(); + await jest.advanceTimersByTimeAsync(1000); + await permissionPromise; + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.PERMISSIONS_STATUS, + expect.stringContaining('granted') + ); + }); + }); + + /** + * Tests for skipSetup() + * + * Allows users to skip Throne setup during onboarding. + * They can connect their device later from settings. + */ + describe('skipSetup', () => { + /** + * Verifies skipped status is persisted to storage. + */ + it('should persist "skipped" status to AsyncStorage', async () => { + const { ThroneService } = createThroneService(); + await ThroneService.skipSetup(); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEYS.PERMISSIONS_STATUS, + expect.stringContaining('skipped') + ); + }); + + /** + * Verifies getPermissionStatus returns 'skipped' after skipSetup. + */ + it('should report status as "skipped" after calling skipSetup', async () => { + const { ThroneService } = createThroneService(); + await ThroneService.skipSetup(); + + const status = await ThroneService.getPermissionStatus(); + expect(status).toBe('skipped'); + }); + }); + + /** + * Tests for getConnectionStatus() + * + * Returns device connection status: + * - 'not_setup': Permission not granted yet + * - 'disconnected': Permission granted but device not paired + * - 'connecting': Attempting to connect + * - 'connected': Device paired and ready + * + * STUB BEHAVIOR: Returns 'not_setup' if not granted, + * otherwise 'disconnected' (never actually connects). + */ + describe('getConnectionStatus', () => { + /** + * Verifies 'not_setup' when permission hasn't been granted. + */ + it('should return "not_setup" when permission is not_determined', async () => { + const { ThroneService } = createThroneService(); + const status = await ThroneService.getConnectionStatus(); + expect(status).toBe('not_setup'); + }); + + /** + * Verifies stub returns 'disconnected' when permission is granted + * (since stub doesn't implement actual device pairing). + */ + it('should return "disconnected" when permission is granted (stub behavior)', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'granted' }) + ); + + const { ThroneService } = createThroneService(); + const status = await ThroneService.getConnectionStatus(); + expect(status).toBe('disconnected'); + }); + + /** + * Verifies 'not_setup' when user skipped setup. + */ + it('should return "not_setup" when permission was skipped', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'skipped' }) + ); + + const { ThroneService } = createThroneService(); + const status = await ThroneService.getConnectionStatus(); + expect(status).toBe('not_setup'); + }); + }); + + /** + * Tests for getMeasurements() + * + * STUB BEHAVIOR: Always returns empty array. + * In production, this would fetch uroflow measurements from Throne API. + * + * TODO: When THRONE_API_IMPLEMENTED is true, update these tests to + * verify real data is returned from the Throne API. + */ + describe('getMeasurements', () => { + /** + * Verifies empty array when permission not granted. + */ + it('should return empty array when permission not granted', async () => { + const { ThroneService } = createThroneService(); + const measurements = await ThroneService.getMeasurements(); + expect(measurements).toEqual([]); + }); + + /** + * STUB-ONLY TEST: Skip when real API is implemented. + * Verifies stub returns empty array even with permission granted. + */ + const stubDataTest = FEATURE_FLAGS.THRONE_API_IMPLEMENTED ? it.skip : it; + stubDataTest('should return empty array when permission granted (stub has no mock data)', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'granted' }) + ); + + const { ThroneService } = createThroneService(); + const measurements = await ThroneService.getMeasurements(); + expect(measurements).toEqual([]); + }); + + /** + * Verifies date range parameters are accepted (for API compatibility). + */ + it('should accept date range parameters without error', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'granted' }) + ); + + const { ThroneService } = createThroneService(); + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-01-31'); + + // Should not throw, even though stub ignores the parameters + const measurements = await ThroneService.getMeasurements(startDate, endDate); + if (!FEATURE_FLAGS.THRONE_API_IMPLEMENTED) { + expect(measurements).toEqual([]); + } else { + // TODO: When implemented, verify measurements are returned + expect(Array.isArray(measurements)).toBe(true); + } + }); + }); + + /** + * Tests for getLatestMeasurement() + * + * STUB BEHAVIOR: Always returns null. + * In production, this would return the most recent uroflow reading. + * + * TODO: When THRONE_API_IMPLEMENTED is true, update these tests to + * verify real measurement data is returned. + */ + describe('getLatestMeasurement', () => { + /** + * Verifies null when permission not granted. + */ + it('should return null when permission not granted', async () => { + const { ThroneService } = createThroneService(); + const measurement = await ThroneService.getLatestMeasurement(); + expect(measurement).toBeNull(); + }); + + /** + * STUB-ONLY TEST: Skip when real API is implemented. + * Verifies stub returns null even with permission (no mock data). + */ + const stubLatestTest = FEATURE_FLAGS.THRONE_API_IMPLEMENTED ? it.skip : it; + stubLatestTest('should return null when permission granted (stub has no mock data)', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ throne: 'granted' }) + ); + + const { ThroneService } = createThroneService(); + const measurement = await ThroneService.getLatestMeasurement(); + expect(measurement).toBeNull(); + }); + }); + + /** + * Tests for isThroneAvailable() + * + * Utility function to check if Throne integration is available. + * STUB BEHAVIOR: Always returns true (UI will show "Coming Soon"). + */ + describe('isThroneAvailable', () => { + /** + * Verifies stub always reports Throne as available. + */ + it('should return true (stub always reports available)', () => { + const { isThroneAvailable } = createThroneService(); + expect(isThroneAvailable()).toBe(true); + }); + }); + + /** + * Tests for error handling + */ + describe('error handling', () => { + /** + * Verifies graceful handling of storage errors during initialization. + * Should default to 'not_determined' status. + */ + it('should handle storage errors and default to not_determined', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const { ThroneService } = createThroneService(); + const status = await ThroneService.getPermissionStatus(); + + expect(status).toBe('not_determined'); + }); + }); +}); diff --git a/homeflow/package-lock.json b/homeflow/package-lock.json index c268235..b6799ef 100644 --- a/homeflow/package-lock.json +++ b/homeflow/package-lock.json @@ -52,9 +52,12 @@ "yup": "^1.7.1" }, "devDependencies": { + "@types/jest": "^29.5.14", "@types/react": "~19.1.17", "eslint": "^9.39.0", "eslint-config-expo": "~10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", "typescript": "~5.9.2" } }, @@ -6335,6 +6338,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -9311,6 +9327,28 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -11374,6 +11412,13 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -11520,6 +11565,13 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -12088,6 +12140,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/nested-error-stacks": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", @@ -14905,6 +14964,85 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -15099,6 +15237,20 @@ "node": "*" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -15766,6 +15918,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/homeflow/package.json b/homeflow/package.json index 9e19998..d89aff2 100644 --- a/homeflow/package.json +++ b/homeflow/package.json @@ -10,7 +10,8 @@ "lint": "expo lint", "typecheck": "tsc --noEmit", "build": "npm run build --workspaces --if-present", - "test": "npm run test --workspaces --if-present" + "test": "npm run test:services && npm run test --workspaces --if-present", + "test:services": "jest --config lib/services/__tests__/jest.config.js" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.1", @@ -54,9 +55,12 @@ "yup": "^1.7.1" }, "devDependencies": { + "@types/jest": "^29.5.14", "@types/react": "~19.1.17", "eslint": "^9.39.0", "eslint-config-expo": "~10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", "typescript": "~5.9.2" }, "private": true, diff --git a/homeflow/packages/chat/src/components/ChatView.tsx b/homeflow/packages/chat/src/components/ChatView.tsx index db85fbd..892b68a 100644 --- a/homeflow/packages/chat/src/components/ChatView.tsx +++ b/homeflow/packages/chat/src/components/ChatView.tsx @@ -29,6 +29,7 @@ export function ChatView({ emptyState, systemPrompt, containerStyle, + onResponse, }: ChatViewProps) { const theme = useMemo( () => mergeChatTheme(userTheme, defaultLightChatTheme), @@ -85,12 +86,16 @@ export function ChatView({ llmMessages.push({ role: 'user', content: userMessage.content }); abortControllerRef.current = new AbortController(); + + // Track accumulated content for the callback + let fullContent = ''; await streamChatCompletion( llmMessages, provider, { onToken: (token) => { + fullContent += token; setMessages((prev) => prev.map((msg) => msg.id === assistantMessage.id @@ -102,6 +107,7 @@ export function ChatView({ onComplete: () => { setIsLoading(false); abortControllerRef.current = null; + onResponse?.(fullContent); }, onError: (error) => { setIsLoading(false); diff --git a/homeflow/packages/chat/src/types.ts b/homeflow/packages/chat/src/types.ts index 2d7de8b..62aaa00 100644 --- a/homeflow/packages/chat/src/types.ts +++ b/homeflow/packages/chat/src/types.ts @@ -128,6 +128,8 @@ export interface ChatViewProps { systemPrompt?: string; /** Custom container style */ containerStyle?: ViewStyle; + /** Callback when the assistant completes a response */ + onResponse?: (content: string) => void; } /** diff --git a/homeflow/packages/questionnaire/src/components/QuestionnaireForm.tsx b/homeflow/packages/questionnaire/src/components/QuestionnaireForm.tsx index 0699db8..0fd6d5a 100644 --- a/homeflow/packages/questionnaire/src/components/QuestionnaireForm.tsx +++ b/homeflow/packages/questionnaire/src/components/QuestionnaireForm.tsx @@ -167,6 +167,8 @@ export function QuestionnaireForm({ initialResponse, submitButtonText = 'Submit', cancelButtonText = 'Cancel', + keyboardVerticalOffset = Platform.OS === 'ios' ? 64 : 0, + scrollContentStyle, }: QuestionnaireFormProps) { const [showCompletion, setShowCompletion] = useState(false); const [completedResponse, setCompletedResponse] = useState(null); @@ -321,7 +323,7 @@ export function QuestionnaireForm({ + keyboardVerticalOffset={keyboardVerticalOffset}>