diff --git a/__mocks__/expoRouterHead.js b/__mocks__/expoRouterHead.js new file mode 100644 index 00000000..46523c83 --- /dev/null +++ b/__mocks__/expoRouterHead.js @@ -0,0 +1,19 @@ +/** + * Mock for expo-router/head module to prevent ESM parsing errors in Jest tests. + * This provides a simple passthrough component that renders children. + */ + +const React = require('react'); + +/** + * Renders the provided children inside a React Fragment. + * @param {{ children?: import('react').ReactNode }} props - Component props. + * @returns {import('react').ReactElement} A React Fragment containing the given children. + */ +function Head({ children }) { + return React.createElement(React.Fragment, null, children); +} + +module.exports = Head; +module.exports.default = Head; +module.exports.__esModule = true; diff --git a/__tests__/lib/analytics.test.ts b/__tests__/lib/analytics.test.ts index 00b981bc..fff6efc1 100644 --- a/__tests__/lib/analytics.test.ts +++ b/__tests__/lib/analytics.test.ts @@ -10,6 +10,7 @@ import { resetAnalytics, AnalyticsEvents, calculateDaysSoberBucket, + __resetForTesting, } from '@/lib/analytics'; const mockInitializePlatformAnalytics = jest.fn(() => Promise.resolve()); @@ -66,6 +67,8 @@ jest.mock('@/lib/logger', () => ({ describe('Unified Analytics Module', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset the module's internal initialization state between tests + __resetForTesting(); mockShouldInitializeAnalytics.mockReturnValue(true); mockIsDebugMode.mockReturnValue(false); }); @@ -96,6 +99,55 @@ describe('Unified Analytics Module', () => { }) ); }); + + it('returns immediately when already completed (with debug logging)', async () => { + mockIsDebugMode.mockReturnValue(true); + + // First call completes initialization + await initializeAnalytics(); + jest.clearAllMocks(); + + // Second call should return immediately + await initializeAnalytics(); + + // Should not call platform init again + expect(mockInitializePlatformAnalytics).not.toHaveBeenCalled(); + }); + + it('concurrent calls await the same Promise', async () => { + // Create a deferred promise to control timing + let resolveInit: () => void; + const initPromise = new Promise((resolve) => { + resolveInit = resolve; + }); + mockInitializePlatformAnalytics.mockReturnValue(initPromise); + + // Start two concurrent initializations + const call1 = initializeAnalytics(); + const call2 = initializeAnalytics(); + + // Platform init should only be called once + expect(mockInitializePlatformAnalytics).toHaveBeenCalledTimes(1); + + // Resolve and wait for both + resolveInit!(); + await Promise.all([call1, call2]); + }); + + it('allows retry after initialization failure', async () => { + // First call fails + mockInitializePlatformAnalytics.mockRejectedValueOnce(new Error('Init failed')); + await initializeAnalytics(); + + // Reset mocks + jest.clearAllMocks(); + mockInitializePlatformAnalytics.mockResolvedValueOnce(undefined); + + // Second call should retry + await initializeAnalytics(); + + expect(mockInitializePlatformAnalytics).toHaveBeenCalledTimes(1); + }); }); describe('trackEvent', () => { diff --git a/__tests__/lib/analytics.web.test.ts b/__tests__/lib/analytics.web.test.ts index 09bef94b..708fc9f9 100644 --- a/__tests__/lib/analytics.web.test.ts +++ b/__tests__/lib/analytics.web.test.ts @@ -20,6 +20,7 @@ import { setUserPropertiesPlatform as setUserPropertiesWeb, trackScreenViewPlatform as trackScreenViewWeb, resetAnalyticsPlatform as resetAnalyticsWeb, + __resetForTesting, } from '@/lib/analytics/platform.web'; const mockLoggerWarn = jest.fn(); @@ -62,6 +63,8 @@ jest.mock('firebase/analytics', () => ({ describe('Web Analytics', () => { beforeEach(() => { + // Reset module state to allow re-initialization between tests + __resetForTesting(); jest.clearAllMocks(); (getApps as jest.Mock).mockReturnValue([]); (isSupported as jest.Mock).mockResolvedValue(true); @@ -171,6 +174,99 @@ describe('Web Analytics', () => { expect.objectContaining({ category: 'ANALYTICS' }) ); }); + + it('returns immediately when already initialized (with debug logging)', async () => { + mockIsDebugMode.mockReturnValue(true); + + // First call initializes + await initializeWebAnalytics(mockConfig); + jest.clearAllMocks(); + + // Second call should return immediately without reinitializing + await initializeWebAnalytics(mockConfig); + + expect(initializeApp).not.toHaveBeenCalled(); + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Analytics already initialized, skipping re-initialization', + expect.objectContaining({ category: 'ANALYTICS' }) + ); + }); + + it('concurrent calls await the same Promise', async () => { + // Create a deferred promise to control timing + let resolveSupported: (value: boolean) => void; + const supportedPromise = new Promise((resolve) => { + resolveSupported = resolve; + }); + (isSupported as jest.Mock).mockReturnValue(supportedPromise); + + // Start two concurrent initializations + const call1 = initializeWebAnalytics(mockConfig); + const call2 = initializeWebAnalytics(mockConfig); + + // isSupported should only be called once (first call starts initialization) + expect(isSupported).toHaveBeenCalledTimes(1); + + // Resolve and wait for both + resolveSupported!(true); + await Promise.all([call1, call2]); + }); + + it('logs waiting message for concurrent calls in debug mode', async () => { + mockIsDebugMode.mockReturnValue(true); + + // Create a deferred promise to control timing + let resolveSupported: (value: boolean) => void; + const supportedPromise = new Promise((resolve) => { + resolveSupported = resolve; + }); + (isSupported as jest.Mock).mockReturnValue(supportedPromise); + + // Start two concurrent initializations + const call1 = initializeWebAnalytics(mockConfig); + const call2 = initializeWebAnalytics(mockConfig); + + // Second call should log waiting message + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Analytics initialization in progress, waiting...', + expect.objectContaining({ category: 'ANALYTICS' }) + ); + + // Resolve and wait for both + resolveSupported!(true); + await Promise.all([call1, call2]); + }); + + it('gracefully handles Firebase failure and marks initialization complete', async () => { + // Firebase init fails, but initialization still completes (graceful degradation) + (isSupported as jest.Mock).mockResolvedValueOnce(true); + (initializeApp as jest.Mock).mockImplementationOnce(() => { + throw new Error('Firebase init failed'); + }); + + await initializeWebAnalytics(mockConfig); + + // Error should be logged + expect(mockLoggerError).toHaveBeenCalledWith( + 'Failed to initialize Firebase Analytics', + expect.any(Error), + expect.objectContaining({ category: 'ANALYTICS' }) + ); + + // Initialization should still be marked complete (graceful degradation) + // Subsequent calls should return early + jest.clearAllMocks(); + mockIsDebugMode.mockReturnValue(true); + + await initializeWebAnalytics(mockConfig); + + // Should log that initialization is already complete + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Analytics already initialized, skipping re-initialization', + expect.objectContaining({ category: 'ANALYTICS' }) + ); + expect(initializeApp).not.toHaveBeenCalled(); + }); }); describe('trackEventWeb', () => { @@ -347,3 +443,509 @@ describe('Web Analytics', () => { }); }); }); + +describe('Web Analytics - New Functionality', () => { + describe('Provider Configuration', () => { + const mockConfig = { + apiKey: 'test-key', + projectId: 'test-project', + appId: 'test-app-id', + measurementId: 'G-XXXXXXXX', + }; + + beforeEach(() => { + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + (isSupported as jest.Mock).mockResolvedValue(true); + mockIsDebugMode.mockReturnValue(false); + }); + + it('initializes with Firebase provider by default', async () => { + await initializeWebAnalytics(mockConfig); + + expect(initializeApp).toHaveBeenCalled(); + expect(getAnalytics).toHaveBeenCalled(); + }); + + it('logs completion with provider information in debug mode', async () => { + mockIsDebugMode.mockReturnValue(true); + + await initializeWebAnalytics(mockConfig); + + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Analytics initialization complete', + expect.objectContaining({ + category: 'ANALYTICS', + provider: expect.any(String), + firebaseEnabled: expect.any(Boolean), + vercelEnabled: expect.any(Boolean), + }) + ); + }); + }); + + describe('Error Handling', () => { + const mockConfig = { + apiKey: 'test-key', + projectId: 'test-project', + appId: 'test-app-id', + measurementId: 'G-XXXXXXXX', + }; + + beforeEach(async () => { + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + (isSupported as jest.Mock).mockResolvedValue(true); + await initializeWebAnalytics(mockConfig); + jest.clearAllMocks(); + }); + + it('handles logEvent errors gracefully', () => { + const error = new Error('Firebase error'); + (logEvent as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + expect(() => trackEventWeb('test_event', { param1: 'value1' })).not.toThrow(); + expect(mockLoggerError).toHaveBeenCalledWith( + 'Failed to track event test_event in Firebase', + error, + expect.objectContaining({ category: 'ANALYTICS' }) + ); + }); + + it('handles setUserId errors gracefully', () => { + const error = new Error('Set user ID failed'); + (setUserId as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + expect(() => setUserIdWeb('user-123')).not.toThrow(); + expect(mockLoggerError).toHaveBeenCalledWith( + 'Failed to set user ID', + error, + expect.objectContaining({ category: 'ANALYTICS' }) + ); + }); + + it('handles setUserProperties errors gracefully', () => { + const error = new Error('Set properties failed'); + (setUserProperties as jest.Mock).mockImplementationOnce(() => { + throw error; + }); + + expect(() => setUserPropertiesWeb({ theme_preference: 'dark' })).not.toThrow(); + expect(mockLoggerError).toHaveBeenCalledWith( + 'Failed to set user properties', + error, + expect.objectContaining({ category: 'ANALYTICS' }) + ); + }); + + it('handles non-Error exceptions in logEvent', () => { + (logEvent as jest.Mock).mockImplementationOnce(() => { + throw 'string error'; + }); + + expect(() => trackEventWeb('test_event')).not.toThrow(); + expect(mockLoggerError).toHaveBeenCalledWith( + 'Failed to track event test_event in Firebase', + expect.any(Error), + expect.objectContaining({ category: 'ANALYTICS' }) + ); + }); + }); + + describe('Idempotency and Re-initialization', () => { + const mockConfig = { + apiKey: 'test-key', + projectId: 'test-project', + appId: 'test-app-id', + measurementId: 'G-XXXXXXXX', + }; + + beforeEach(() => { + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + (isSupported as jest.Mock).mockResolvedValue(true); + mockIsDebugMode.mockReturnValue(false); + }); + + it('prevents multiple initialization attempts', async () => { + await initializeWebAnalytics(mockConfig); + const firstCallCount = initializeApp.mock.calls.length; + + // Try to initialize again + await initializeWebAnalytics(mockConfig); + + // Should not call initializeApp again + expect(initializeApp).toHaveBeenCalledTimes(firstCallCount); + }); + + it('logs debug message when skipping re-initialization', async () => { + mockIsDebugMode.mockReturnValue(true); + await initializeWebAnalytics(mockConfig); + + jest.clearAllMocks(); + + await initializeWebAnalytics(mockConfig); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Analytics already initialized, skipping re-initialization', + expect.objectContaining({ category: 'ANALYTICS' }) + ); + }); + + it('allows re-initialization after __resetForTesting', async () => { + await initializeWebAnalytics(mockConfig); + + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + + await initializeWebAnalytics(mockConfig); + + // Should call initializeApp again after reset + expect(initializeApp).toHaveBeenCalledTimes(1); + }); + }); + + describe('Analytics Not Initialized', () => { + beforeEach(() => { + __resetForTesting(); + jest.clearAllMocks(); + mockIsDebugMode.mockReturnValue(false); + }); + + it('logs debug message when tracking event without initialization', () => { + mockIsDebugMode.mockReturnValue(true); + + trackEventWeb('test_event', { param1: 'value1' }); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + expect.stringContaining('Event (not sent - no provider initialized)'), + expect.objectContaining({ + category: 'ANALYTICS', + event_params: expect.any(Object), + }) + ); + }); + + it('logs debug message when setting user ID without initialization', () => { + mockIsDebugMode.mockReturnValue(true); + + setUserIdWeb('user-123'); + + // Wait for async hash operation + return new Promise((resolve) => { + setTimeout(() => { + expect(mockLoggerDebug).toHaveBeenCalledWith( + expect.stringContaining('setUserId (not sent - Firebase not initialized)'), + expect.objectContaining({ category: 'ANALYTICS' }) + ); + resolve(undefined); + }, 10); + }); + }); + + it('logs debug message when clearing user ID (null) without initialization', () => { + mockIsDebugMode.mockReturnValue(true); + + setUserIdWeb(null); + + // Wait for async hash operation (even for null, there's async processing) + return new Promise((resolve) => { + setTimeout(() => { + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'setUserId (not sent - Firebase not initialized): null', + expect.objectContaining({ category: 'ANALYTICS' }) + ); + resolve(undefined); + }, 10); + }); + }); + + it('logs debug message when setting user properties without initialization', () => { + mockIsDebugMode.mockReturnValue(true); + + setUserPropertiesWeb({ theme_preference: 'dark' }); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'setUserProperties (not sent - Firebase not initialized)', + expect.objectContaining({ + category: 'ANALYTICS', + user_properties: expect.any(Object), + }) + ); + }); + + it('does not call Firebase methods when not initialized', () => { + trackEventWeb('test_event'); + setUserIdWeb('user-123'); + setUserPropertiesWeb({ theme_preference: 'dark' }); + + expect(logEvent).not.toHaveBeenCalled(); + expect(setUserId).not.toHaveBeenCalled(); + expect(setUserProperties).not.toHaveBeenCalled(); + }); + }); + + describe('User Properties Filtering', () => { + beforeEach(async () => { + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + (isSupported as jest.Mock).mockResolvedValue(true); + await initializeWebAnalytics({ + apiKey: 'test-key', + projectId: 'test-project', + appId: 'test-app-id', + measurementId: 'G-XXXXXXXX', + }); + jest.clearAllMocks(); + }); + + it('filters out undefined values from user properties', () => { + const props = { + theme_preference: 'dark' as const, + has_sponsor: undefined, + days_sober_bucket: '31-90' as const, + }; + + setUserPropertiesWeb(props); + + // Firebase should only receive defined values + expect(setUserProperties).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + theme_preference: 'dark', + days_sober_bucket: '31-90', + }) + ); + + // Should not include undefined values + const callArgs = (setUserProperties as jest.Mock).mock.calls[0][1]; + expect(callArgs).not.toHaveProperty('has_sponsor'); + }); + + it('preserves boolean false values', () => { + const props = { + has_sponsor: false, + has_sponsees: false, + }; + + setUserPropertiesWeb(props); + + expect(setUserProperties).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + has_sponsor: false, + has_sponsees: false, + }) + ); + }); + + it('handles empty properties object', () => { + setUserPropertiesWeb({}); + + expect(setUserProperties).toHaveBeenCalledWith(expect.anything(), {}); + }); + + it('handles all undefined properties', () => { + const props = { + theme_preference: undefined, + has_sponsor: undefined, + }; + + setUserPropertiesWeb(props); + + expect(setUserProperties).toHaveBeenCalledWith(expect.anything(), {}); + }); + }); + + describe('Parameter Sanitization for Logging', () => { + beforeEach(async () => { + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + (isSupported as jest.Mock).mockResolvedValue(true); + mockIsDebugMode.mockReturnValue(true); + await initializeWebAnalytics({ + apiKey: 'test-key', + projectId: 'test-project', + appId: 'test-app-id', + measurementId: 'G-XXXXXXXX', + }); + jest.clearAllMocks(); + }); + + it('logs params without PII in debug mode', () => { + trackEventWeb('test_event', { + task_id: '123', + count: 5, + }); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Event: test_event', + expect.objectContaining({ + category: 'ANALYTICS', + event_params: expect.objectContaining({ + task_id: '123', + count: 5, + }), + }) + ); + }); + + it('redacts PII-prone keys from logged params', () => { + trackEventWeb('test_event', { + email: 'user@example.com', + task_id: '123', + }); + + const debugCall = mockLoggerDebug.mock.calls.find((call) => + call[0].includes('Event: test_event') + ); + expect(debugCall).toBeDefined(); + expect(debugCall[1].event_params.email).toBe('[Filtered]'); + expect(debugCall[1].event_params.task_id).toBe('123'); + }); + + it('recursively sanitizes nested objects', () => { + trackEventWeb('test_event', { + metadata: { + email: 'user@example.com', + task_id: '123', + }, + }); + + const debugCall = mockLoggerDebug.mock.calls.find((call) => + call[0].includes('Event: test_event') + ); + expect(debugCall).toBeDefined(); + expect(debugCall[1].event_params.metadata.email).toBe('[Filtered]'); + expect(debugCall[1].event_params.metadata.task_id).toBe('123'); + }); + + it('handles params with reserved logger keys', () => { + trackEventWeb('test_event', { + error_message: 'this should be filtered', + task_id: '123', + }); + + const debugCall = mockLoggerDebug.mock.calls.find((call) => + call[0].includes('Event: test_event') + ); + expect(debugCall).toBeDefined(); + // error_message should be filtered to prevent overwriting logger metadata + expect(debugCall[1].event_params).not.toHaveProperty('error_message'); + expect(debugCall[1].event_params.task_id).toBe('123'); + }); + }); + + describe('__resetForTesting', () => { + it('throws error when called outside test environment', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + expect(() => __resetForTesting()).toThrow( + '__resetForTesting should only be called in test environments' + ); + + process.env.NODE_ENV = originalEnv; + }); + + it('successfully resets state in test environment', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + + expect(() => __resetForTesting()).not.toThrow(); + + process.env.NODE_ENV = originalEnv; + }); + }); + + describe('Edge Cases', () => { + const mockConfig = { + apiKey: 'test-key', + projectId: 'test-project', + appId: 'test-app-id', + measurementId: 'G-XXXXXXXX', + }; + + beforeEach(async () => { + __resetForTesting(); + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([]); + (isSupported as jest.Mock).mockResolvedValue(true); + await initializeWebAnalytics(mockConfig); + jest.clearAllMocks(); + }); + + it('handles trackEvent with null params', () => { + expect(() => trackEventWeb('test_event', null as any)).not.toThrow(); + expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'test_event', null); + }); + + it('handles trackEvent with empty object params', () => { + trackEventWeb('test_event', {}); + expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'test_event', {}); + }); + + it('handles empty screen name in trackScreenView', () => { + trackScreenViewWeb(''); + expect(logEvent).toHaveBeenCalledWith(expect.anything(), 'screen_view', { + screen_name: '', + screen_class: '', + }); + }); + + it('handles very long event names', () => { + const longName = 'a'.repeat(1000); + trackEventWeb(longName); + expect(logEvent).toHaveBeenCalledWith(expect.anything(), longName, undefined); + }); + + it('handles special characters in event names', () => { + const specialName = 'event_with-special.chars!@#$%'; + trackEventWeb(specialName); + expect(logEvent).toHaveBeenCalledWith(expect.anything(), specialName, undefined); + }); + + it('handles params with circular references gracefully', () => { + const circular: any = { prop: 'value' }; + circular.self = circular; + + // This should not crash, even if logging might fail internally + expect(() => trackEventWeb('test_event', circular)).not.toThrow(); + }); + + it('correctly handles shared objects (not circular) in params', () => { + mockIsDebugMode.mockReturnValue(true); + + // Shared object used in multiple places - NOT circular, just shared + const shared = { id: '123', name: 'test' }; + const params = { + first: shared, + second: shared, + nested: { inner: shared }, + }; + + trackEventWeb('test_event', params); + + // Should log all instances of shared object correctly (not as [Circular]) + const debugCall = mockLoggerDebug.mock.calls.find((call) => + call[0].includes('Event: test_event') + ); + expect(debugCall).toBeDefined(); + + // All references to the shared object should be sanitized properly, not marked [Circular] + expect(debugCall[1].event_params.first).toEqual({ id: '123', name: '[Filtered]' }); + expect(debugCall[1].event_params.second).toEqual({ id: '123', name: '[Filtered]' }); + expect(debugCall[1].event_params.nested.inner).toEqual({ id: '123', name: '[Filtered]' }); + }); + }); +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 8186d5c0..aa40c8e2 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -28,6 +28,7 @@ import { useNavigationContainerRef, useRootNavigationState, } from 'expo-router'; +import Head from 'expo-router/head'; import { StatusBar } from 'expo-status-bar'; import { useFrameworkReady } from '@/hooks/useFrameworkReady'; import { AuthProvider, useAuth } from '@/contexts/AuthContext'; @@ -123,16 +124,41 @@ function RootLayoutNav() { } }, [user, profile, segments, loading, router, navigatorReady]); + // SEO meta tags rendered unconditionally for search engine and social media crawlers + const seoHead = ( + + Sobriety Waypoint + + + {/* Open Graph / Facebook */} + + + + + + + {/* Twitter */} + + + + + + ); + if (loading) { return ( - - - + <> + {seoHead} + + + + ); } return ( <> + {seoHead} diff --git a/assets/images/banner.png b/assets/images/banner.png new file mode 100644 index 00000000..36e787da Binary files /dev/null and b/assets/images/banner.png differ diff --git a/jest.config.js b/jest.config.js index 6bc131a1..d1d87b39 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,7 @@ module.exports = { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', }, transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|@sentry|native-base|react-native-svg|lucide-react-native|@supabase)', + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|expo-router|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|@sentry|native-base|react-native-svg|lucide-react-native|@supabase)', ], setupFiles: ['/jest.setup.js'], testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], @@ -22,6 +22,8 @@ module.exports = { '\\.(jpg|jpeg|png|gif|svg)$': '/__mocks__/fileMock.js', // Mock expo virtual modules to prevent ESM parsing errors '^expo/virtual/(.*)$': '/__mocks__/expoVirtualMock.js', + // Mock expo-router/head to prevent ESM parsing errors + '^expo-router/head$': '/__mocks__/expoRouterHead.js', }, collectCoverageFrom: [ 'app/**/*.{ts,tsx}', diff --git a/lib/analytics/index.ts b/lib/analytics/index.ts index 9d35d2a7..0c271307 100644 --- a/lib/analytics/index.ts +++ b/lib/analytics/index.ts @@ -43,6 +43,33 @@ import { export { AnalyticsEvents, type AnalyticsEventName } from '@/types/analytics'; export { calculateDaysSoberBucket } from '@/lib/analytics-utils'; +// ============================================================================= +// Module State +// ============================================================================= +/** + * Initialization state to prevent race conditions at the public API level. + * + * Uses Promise-based pattern instead of boolean flag: + * - null: not started + * - Promise: initialization in progress (concurrent callers await the same Promise) + * - 'completed': successfully initialized + * - 'skipped': analytics disabled (no config) + * + * This prevents the race condition where resetting a boolean flag on failure + * could allow concurrent calls to both proceed with initialization. + */ +let initializationPromise: Promise | null = null; +let initializationState: 'pending' | 'completed' | 'skipped' | 'failed' | null = null; + +/** + * Internal initialization logic. Called by initializeAnalytics wrapper. + * + * @param config - Analytics configuration + */ +async function doInitialize(config: AnalyticsConfig): Promise { + await initializePlatformAnalytics(config); +} + /** * Initialize Firebase Analytics for the app. * @@ -50,6 +77,11 @@ export { calculateDaysSoberBucket } from '@/lib/analytics-utils'; * On native platforms, Firebase is configured via config files. * On web, it uses environment variables. * + * This function uses a Promise-based pattern to prevent race conditions: + * - Concurrent calls during initialization will await the same Promise + * - Once completed, subsequent calls return immediately + * - On failure, retry is allowed (state is reset) + * * @example * ```ts * // In app/_layout.tsx @@ -58,12 +90,35 @@ export { calculateDaysSoberBucket } from '@/lib/analytics-utils'; * ``` */ export async function initializeAnalytics(): Promise { + // Already completed or skipped - return immediately + if (initializationState === 'completed' || initializationState === 'skipped') { + if (isDebugMode()) { + logger.debug(`Analytics already ${initializationState}, skipping`, { + category: LogCategory.ANALYTICS, + }); + } + return; + } + + // Initialization in progress - wait for the existing Promise + // This prevents race conditions from concurrent calls + if (initializationPromise !== null && initializationState === 'pending') { + if (isDebugMode()) { + logger.debug('Analytics initialization in progress, waiting...', { + category: LogCategory.ANALYTICS, + }); + } + return initializationPromise; + } + + // Check if analytics should be initialized if (!shouldInitializeAnalytics()) { if (isDebugMode()) { logger.warn('Firebase not configured - analytics disabled', { category: LogCategory.ANALYTICS, }); } + initializationState = 'skipped'; return; } @@ -87,7 +142,24 @@ export async function initializeAnalytics(): Promise { }); } - await initializePlatformAnalytics(config); + // Start new initialization + initializationState = 'pending'; + initializationPromise = doInitialize(config) + .then(() => { + initializationState = 'completed'; + }) + .catch((error) => { + // Reset state to allow retry + initializationState = 'failed'; + initializationPromise = null; + const err = error instanceof Error ? error : new Error(String(error)); + logger.error('Failed to initialize analytics platform', err, { + category: LogCategory.ANALYTICS, + }); + // Don't rethrow - analytics failures shouldn't crash the app + }); + + return initializationPromise; } /** @@ -156,28 +228,44 @@ export function setUserProperties(properties: UserProperties): void { } /** - * Tracks a screen view event. + * Record a screen view for analytics. * - * This is typically called automatically by navigation tracking, - * but can be called manually for non-standard screens. + * Typically invoked automatically by navigation tracking; call manually for non-standard or ephemeral screens. * - * @param screenName - The name of the screen - * @param screenClass - Optional screen class name - * - * @example - * ```ts - * trackScreenView('HomeScreen', 'TabScreen'); - * ``` + * @param screenName - The logical name of the screen (e.g., "Home", "Settings") + * @param screenClass - Optional class or category of the screen (e.g., "TabScreen") */ export function trackScreenView(screenName: string, screenClass?: string): void { trackScreenViewPlatform(screenName, screenClass); } /** - * Reset analytics state for the current user. + * Resets analytics state for the current user. * * Clears the analytics user identifier and any user-specific analytics data. + * + * Note: This does NOT reset the analytics initialization state. + * Analytics initialization persists after reset. */ export async function resetAnalytics(): Promise { await resetAnalyticsPlatform(); } + +// ============================================================================= +// Testing Utilities +// ============================================================================= +/** + * Reset the module's analytics initialization state for tests. + * + * Clears the internal initialization state so the module can be re-initialized in a test. + * + * @throws Will throw an Error if called when NODE_ENV is not 'test'. + * @internal + */ +export function __resetForTesting(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('__resetForTesting should only be called in test environments'); + } + initializationState = null; + initializationPromise = null; +} diff --git a/lib/analytics/platform.native.ts b/lib/analytics/platform.native.ts index 09001bc8..4e10d99e 100644 --- a/lib/analytics/platform.native.ts +++ b/lib/analytics/platform.native.ts @@ -50,10 +50,9 @@ function getAnalyticsInstance(): ReturnType | null { } /** - * Initialize Firebase Analytics for native platforms. + * Initializes Firebase Analytics for native platforms using the bundled native configuration. * - * On iOS and Android the native Firebase configuration files are used; the - * optional `config` argument is ignored by the native implementation. + * The optional `_config` argument is ignored on iOS and Android. * * @param _config - Optional analytics configuration; ignored on native platforms */ @@ -145,10 +144,10 @@ function hashUserIdForLogging(input: string | null): string | null { } /** - * Sets the user ID for analytics. + * Set the analytics user ID for the current session. * - * This function is synchronous (fire-and-forget) to match the public API in index.ts. - * Errors are caught and logged but not propagated to avoid unhandled promise rejections. + * Passing `null` clears the current user ID. Errors encountered while setting the ID + * are caught and logged and will not be propagated. * * @param userId - The user ID to set, or `null` to clear the current user ID */ @@ -213,15 +212,13 @@ function sanitizeUserPropertiesForLogging( } /** - * Sets user properties for analytics. - * - * This function is synchronous (fire-and-forget) to match the public API in index.ts. - * Errors are caught and logged but not propagated to avoid unhandled promise rejections. + * Set analytics user properties for the native platform. * - * Firebase requires user properties to be string or null. Boolean values are - * converted to strings ('true'/'false') to preserve semantic meaning. + * Accepts an object mapping property names to values; `null` clears a property and `undefined` entries are ignored. + * Boolean values are converted to the strings `'true'` or `'false'` to meet Firebase requirements; other non-null values are converted to strings. + * Errors from the underlying analytics call are logged and not rethrown. * - * @param properties - Object mapping property names to string values; use `null` to clear a property + * @param properties - Mapping of user property names to string, boolean, `null`, or `undefined` values */ export function setUserPropertiesPlatform(properties: UserProperties): void { if (isDebugMode()) { @@ -255,13 +252,9 @@ export function setUserPropertiesPlatform(properties: UserProperties): void { } /** - * Tracks a screen view event. + * Record a screen view in analytics. * - * Uses logEvent with 'screen_view' instead of the dedicated logScreenView function - * to avoid deprecation warnings in React Native Firebase v22+. - * - * This function is synchronous (fire-and-forget) to match the public API in index.ts. - * Errors are caught and logged but not propagated to avoid unhandled promise rejections. + * This is a fire-and-forget call: it enqueues a 'screen_view' event and catches/logs any errors without throwing. * * @param screenName - The displayed name of the screen to record * @param screenClass - Optional screen class; defaults to `screenName` when omitted @@ -295,7 +288,9 @@ export function trackScreenViewPlatform(screenName: string, screenClass?: string } /** - * Clear analytics state and stored analytics data (commonly used on user logout). + * Clear the native Firebase Analytics instance state and stored analytics data, typically used on user logout. + * + * If Firebase Analytics is unavailable, logs a warning and returns without error. Any errors encountered while resetting are logged and not rethrown. */ export async function resetAnalyticsPlatform(): Promise { try { diff --git a/lib/analytics/platform.ts b/lib/analytics/platform.ts index c97d8683..b636b4c3 100644 --- a/lib/analytics/platform.ts +++ b/lib/analytics/platform.ts @@ -12,11 +12,9 @@ import type { EventParams, UserProperties, AnalyticsConfig } from '@/types/analy import { logger, LogCategory } from '@/lib/logger'; /** - * Provide a fallback analytics initializer for unsupported platforms. + * Logs a warning that the fallback analytics implementation is active on unsupported platforms. * - * Logs a warning that the fallback implementation is in use and performs no analytics initialization. - * - * @param _config - Optional analytics configuration; this implementation ignores the value + * @param _config - Optional analytics configuration; ignored by the fallback implementation */ export async function initializePlatformAnalytics(_config?: AnalyticsConfig): Promise { logger.warn('Analytics: Using fallback implementation (platform not supported)', { diff --git a/lib/analytics/platform.web.ts b/lib/analytics/platform.web.ts index 5e3a0a8c..46014473 100644 --- a/lib/analytics/platform.web.ts +++ b/lib/analytics/platform.web.ts @@ -73,6 +73,20 @@ let analytics: Analytics | null = null; let app: FirebaseApp | null = null; let vercelAnalytics: VercelAnalytics | null = null; +/** + * Initialization state to prevent race conditions. + * + * Uses Promise-based pattern instead of boolean flag: + * - null: not started + * - Promise: initialization in progress (concurrent callers await the same Promise) + * - 'completed': successfully initialized + * + * This prevents the race condition where resetting a boolean flag on failure + * could allow concurrent calls to both proceed with initialization. + */ +let initializationPromise: Promise | null = null; +let initializationState: 'pending' | 'completed' | 'failed' | null = null; + // ============================================================================= // Constants (continued) // ============================================================================= @@ -222,11 +236,20 @@ async function hashUserId(input: string | null): Promise { * 1. Removing reserved logger keys to prevent overwrites * 2. Redacting PII-prone keys to prevent data leaks * 3. Returning a sanitized object suitable for nested logging + * 4. Handling circular references gracefully + * + * The `ancestors` WeakSet tracks objects in the current recursion path only. + * This correctly handles shared objects that appear in multiple non-circular positions + * (e.g., the same config object used as values for different keys). * * @param params - The original event params to sanitize + * @param ancestors - WeakSet tracking objects in current recursion path (ancestors only) * @returns Sanitized params object with PII redacted and reserved keys removed */ -function sanitizeParamsForLogging(params?: EventParams): Record { +function sanitizeParamsForLogging( + params?: EventParams, + ancestors: WeakSet = new WeakSet() +): Record { if (!params) { return {}; } @@ -245,9 +268,17 @@ function sanitizeParamsForLogging(params?: EventParams): Record continue; } - // For nested objects, recursively sanitize + // For nested objects, recursively sanitize with circular reference detection if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - sanitized[key] = sanitizeParamsForLogging(value as EventParams); + // Detect circular references (object is an ancestor in current recursion path) + if (ancestors.has(value)) { + sanitized[key] = '[Circular]'; + continue; + } + // Add to ancestors for this recursion path, then remove after processing + ancestors.add(value); + sanitized[key] = sanitizeParamsForLogging(value as EventParams, ancestors); + ancestors.delete(value); } else { sanitized[key] = value; } @@ -334,26 +365,17 @@ function dispatchEvent(eventName: string, params?: EventParams): void { // ============================================================================= // Main Logic // ============================================================================= + /** - * Initializes analytics providers for web. - * - * Initializes Firebase Analytics and/or Vercel Analytics based on configuration. - * Firebase requires explicit configuration; Vercel Analytics auto-detects if installed. - * - * @param config - Firebase configuration (required if Firebase is enabled) - * @returns Promise that resolves when initialization is complete + * Internal initialization logic. Called by initializePlatformAnalytics wrapper. + * This function performs the actual work and may throw on critical errors. * - * @example - * ```ts - * await initializePlatformAnalytics({ - * apiKey: 'your-api-key', - * projectId: 'your-project', - * appId: 'your-app-id', - * measurementId: 'G-XXXXXXXXXX' - * }); - * ``` + * @param config - Firebase configuration + * @throws Error if Firebase initialization fails critically */ -export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { +async function doInitialize(config: AnalyticsConfig): Promise { + let firebaseError: Error | null = null; + // Initialize Firebase if enabled if (IS_FIREBASE_ENABLED) { try { @@ -392,15 +414,15 @@ export async function initializePlatformAnalytics(config: AnalyticsConfig): Prom } } } catch (error) { - logger.error( - 'Failed to initialize Firebase Analytics', - error instanceof Error ? error : new Error(String(error)), - { category: LogCategory.ANALYTICS } - ); + // Capture error but don't return - still initialize Vercel if enabled + firebaseError = error instanceof Error ? error : new Error(String(error)); + logger.error('Failed to initialize Firebase Analytics', firebaseError, { + category: LogCategory.ANALYTICS, + }); } } - // Initialize Vercel Analytics if enabled + // Initialize Vercel Analytics if enabled - independent of Firebase success/failure await initializeVercelAnalytics(); if (isDebugMode()) { @@ -409,8 +431,70 @@ export async function initializePlatformAnalytics(config: AnalyticsConfig): Prom provider: ANALYTICS_PROVIDER, firebaseEnabled: IS_FIREBASE_ENABLED && analytics !== null, vercelEnabled: IS_VERCEL_ENABLED && vercelAnalytics !== null, + firebaseError: firebaseError ? firebaseError.message : null, }); } + + // Note: We intentionally do NOT throw on Firebase failures. + // Analytics initialization failures should be handled gracefully since they're + // not critical to app functionality. The error is logged above for monitoring. +} + +/** + * Initialize configured analytics providers for the web platform. + * + * This function uses a Promise-based pattern to prevent race conditions: + * - Concurrent calls during initialization will await the same Promise + * - Once completed, subsequent calls return immediately + * - On failure, retry is allowed (state is reset) + * + * Firebase and Vercel Analytics are initialized independently - a Firebase failure + * will not prevent Vercel Analytics from initializing when provider is 'both'. + * + * Initialization errors are handled gracefully and logged. Analytics failures do not + * throw exceptions since they are not critical to app functionality. + * + * @param config - Firebase configuration required when Firebase analytics is enabled. + * If Firebase is enabled but config is missing or invalid (empty apiKey, projectId, or appId), + * Firebase initialization will fail and be logged. If Vercel is also enabled (provider='both'), + * Vercel Analytics will still be initialized. + */ +export async function initializePlatformAnalytics(config: AnalyticsConfig): Promise { + // Already completed successfully - return immediately + if (initializationState === 'completed') { + if (isDebugMode()) { + logger.debug('Analytics already initialized, skipping re-initialization', { + category: LogCategory.ANALYTICS, + }); + } + return; + } + + // Initialization in progress - wait for the existing Promise + // This prevents race conditions from concurrent calls + if (initializationPromise !== null && initializationState === 'pending') { + if (isDebugMode()) { + logger.debug('Analytics initialization in progress, waiting...', { + category: LogCategory.ANALYTICS, + }); + } + return initializationPromise; + } + + // Start new initialization + initializationState = 'pending'; + initializationPromise = doInitialize(config) + .then(() => { + initializationState = 'completed'; + }) + .catch((error) => { + // Reset state to allow retry + initializationState = 'failed'; + initializationPromise = null; + throw error; + }); + + return initializationPromise; } /** @@ -554,10 +638,9 @@ export function trackScreenViewPlatform(screenName: string, screenClass?: string } /** - * Resets analytics for logout. + * Reset analytics state for the current user. * - * Clears user ID and user properties in Firebase Analytics. - * Vercel Analytics does not support reset operations. + * Clears the Firebase Analytics user ID and user properties. No-op for Vercel Analytics because it does not expose a reset API. */ export async function resetAnalyticsPlatform(): Promise { if (isDebugMode()) { @@ -567,6 +650,28 @@ export async function resetAnalyticsPlatform(): Promise { setUserIdPlatform(null); } +// ============================================================================= +// Testing Utilities +// ============================================================================= +/** + * Reset the module's analytics initialization state for tests. + * + * Clears the internal initialization state and removes stored Firebase and Vercel analytics instances so the module can be re-initialized in a test. + * + * @throws Will throw an Error if called when NODE_ENV is not 'test'. + * @internal + */ +export function __resetForTesting(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error('__resetForTesting should only be called in test environments'); + } + initializationState = null; + initializationPromise = null; + analytics = null; + app = null; + vercelAnalytics = null; +} + // ============================================================================= // Exports // ============================================================================= diff --git a/plugins/withFirebaseConfig.js b/plugins/withFirebaseConfig.js index 025bddb2..9b98562a 100644 --- a/plugins/withFirebaseConfig.js +++ b/plugins/withFirebaseConfig.js @@ -69,12 +69,26 @@ function detectConfigFormat(content) { } /** - * Writes content from an EAS secret to a target file. - * EAS FILE_BASE64 secrets provide base64-encoded content directly in the env var. + * Ensure a file at targetPath is created from an EAS secret value. * - * @param {string} secretValue - The secret value (base64 content or file path) - * @param {string} targetPath - Where to write the file - * @returns {boolean} True if file was written successfully + * If secretValue is falsy, the function does nothing and returns `false`. + * + * The function handles three input formats: + * 1. **Absolute file path**: If secretValue is an existing absolute filesystem path, + * that file is copied to targetPath. + * 2. **Raw JSON/plist content**: If secretValue is already valid JSON (starts with `{`) + * or plist XML (starts with `/GoogleService-Info.plist from the + * EAS secret `GOOGLE_SERVICE_INFO_PLIST`, falling back to copying a local + * GoogleService-Info.plist from the project root if the secret is not provided. + * + * @param {import('@expo/config-plugins').ConfigPlugin} config - Expo config to modify. + * @returns {import('@expo/config-plugins').ConfigPlugin} The updated Expo config. */ function withIosFirebaseConfig(config) { return withDangerousMod(config, [