diff --git a/src/__tests__/env.test.ts b/src/__tests__/env.test.ts new file mode 100644 index 0000000..fa03ff5 --- /dev/null +++ b/src/__tests__/env.test.ts @@ -0,0 +1,311 @@ +import { validateEnvVariables, requireEnvVariables, getEnv } from '../config/env'; + +const ENV_KEYS = [ + 'EXPO_PUBLIC_API_BASE_URL', + 'EXPO_PUBLIC_SOCKET_URL', + 'EXPO_PUBLIC_APP_ENV', + 'EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS', + 'EXPO_PUBLIC_STORYBOOK', + 'EXPO_PUBLIC_SENTRY_ENABLED', +]; + +const savedValues: Record = {}; + +function setEnv(vars: Record) { + for (const key of ENV_KEYS) { + savedValues[key] = process.env[key]; + if (key in vars) { + if (vars[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = vars[key]; + } + } + } +} + +afterEach(() => { + for (const key of ENV_KEYS) { + if (savedValues[key] !== undefined) { + process.env[key] = savedValues[key]; + } else { + delete process.env[key]; + } + } +}); + +describe('validateEnvVariables', () => { + it('returns valid when all required vars are present with correct formats', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: undefined, + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: undefined, + EXPO_PUBLIC_STORYBOOK: undefined, + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(true); + expect(result.message).toBeUndefined(); + }); + + it('returns invalid when EXPO_PUBLIC_API_BASE_URL is missing', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: undefined, + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_API_BASE_URL'); + }); + + it('returns invalid when EXPO_PUBLIC_API_BASE_URL is not https', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'http://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('must use https://'); + }); + + it('returns invalid for completely malformed EXPO_PUBLIC_API_BASE_URL', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'not-a-url', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('Invalid URL'); + }); + + it('returns invalid when EXPO_PUBLIC_SOCKET_URL is missing', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_SOCKET_URL'); + }); + + it('returns invalid when EXPO_PUBLIC_SOCKET_URL is not ws:// or wss://', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'http://socket.example.com', + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('ws:// or wss://'); + }); + + it('returns valid when EXPO_PUBLIC_SOCKET_URL uses ws://', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'ws://socket.example.com', + EXPO_PUBLIC_APP_ENV: undefined, + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: undefined, + EXPO_PUBLIC_STORYBOOK: undefined, + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(true); + }); + + it('returns valid when optional EXPO_PUBLIC_APP_ENV is absent', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: undefined, + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: undefined, + EXPO_PUBLIC_STORYBOOK: undefined, + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(true); + }); + + it('returns invalid when EXPO_PUBLIC_APP_ENV has invalid value', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: 'staging', + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: undefined, + EXPO_PUBLIC_STORYBOOK: undefined, + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_APP_ENV'); + }); + + it('returns valid when EXPO_PUBLIC_APP_ENV is development', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: 'development', + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: undefined, + EXPO_PUBLIC_STORYBOOK: undefined, + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(true); + }); + + it('returns invalid when EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS is invalid', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: 'yes', + EXPO_PUBLIC_STORYBOOK: undefined, + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS'); + }); + + it('returns invalid when EXPO_PUBLIC_STORYBOOK has invalid value', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_STORYBOOK: 'maybe', + EXPO_PUBLIC_SENTRY_ENABLED: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_STORYBOOK'); + }); + + it('returns invalid when EXPO_PUBLIC_SENTRY_ENABLED has invalid value', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_SENTRY_ENABLED: 'on', + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_SENTRY_ENABLED'); + }); + + it('returns invalid for both missing required vars', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: undefined, + EXPO_PUBLIC_SOCKET_URL: undefined, + }); + + const result = validateEnvVariables(); + expect(result.valid).toBe(false); + expect(result.message).toContain('EXPO_PUBLIC_API_BASE_URL'); + expect(result.message).toContain('EXPO_PUBLIC_SOCKET_URL'); + }); +}); + +describe('requireEnvVariables', () => { + it('returns config object when all required vars are valid', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: undefined, + }); + + const config = requireEnvVariables(); + expect(config.EXPO_PUBLIC_API_BASE_URL).toBe('https://api.example.com'); + expect(config.EXPO_PUBLIC_SOCKET_URL).toBe('wss://socket.example.com'); + }); + + it('throws when required vars are missing', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: undefined, + EXPO_PUBLIC_SOCKET_URL: undefined, + }); + + expect(() => requireEnvVariables()).toThrow('Environment Configuration Error'); + }); + + it('throws when URL formats are invalid', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'http://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'not-ws', + }); + + expect(() => requireEnvVariables()).toThrow('Environment Configuration Error'); + }); + + it('defaults EXPO_PUBLIC_APP_ENV to development when not production', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: undefined, + }); + + const config = requireEnvVariables(); + expect(config.EXPO_PUBLIC_APP_ENV).toBe('development'); + }); + + it('sets EXPO_PUBLIC_APP_ENV to production when explicitly set', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + EXPO_PUBLIC_APP_ENV: 'production', + }); + + const config = requireEnvVariables(); + expect(config.EXPO_PUBLIC_APP_ENV).toBe('production'); + }); +}); + +describe('getEnv', () => { + it('returns value for EXPO_PUBLIC_API_BASE_URL', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + }); + + expect(getEnv('EXPO_PUBLIC_API_BASE_URL')).toBe('https://api.example.com'); + }); + + it('returns value for EXPO_PUBLIC_SOCKET_URL', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + }); + + expect(getEnv('EXPO_PUBLIC_SOCKET_URL')).toBe('wss://socket.example.com'); + }); + + it('throws when EXPO_PUBLIC_API_BASE_URL is missing', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: undefined, + EXPO_PUBLIC_SOCKET_URL: 'wss://socket.example.com', + }); + + expect(() => getEnv('EXPO_PUBLIC_API_BASE_URL')).toThrow( + 'Environment variable EXPO_PUBLIC_API_BASE_URL is not set' + ); + }); + + it('throws when EXPO_PUBLIC_SOCKET_URL is missing', () => { + setEnv({ + EXPO_PUBLIC_API_BASE_URL: 'https://api.example.com', + EXPO_PUBLIC_SOCKET_URL: undefined, + }); + + expect(() => getEnv('EXPO_PUBLIC_SOCKET_URL')).toThrow( + 'Environment variable EXPO_PUBLIC_SOCKET_URL is not set' + ); + }); +}); diff --git a/src/__tests__/linkingConfig.test.ts b/src/__tests__/linkingConfig.test.ts new file mode 100644 index 0000000..545f425 --- /dev/null +++ b/src/__tests__/linkingConfig.test.ts @@ -0,0 +1,240 @@ +import * as Linking from 'expo-linking'; +import * as Notifications from 'expo-notifications'; + +import { linking, buildScreenUrl, setDeepLinkAnalyticsHandler } from '../navigation/linking'; +import { NotificationType } from '../types/notifications'; + +jest.mock('../utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + infoSync: jest.fn(), + warnSync: jest.fn(), + errorSync: jest.fn(), + debugSync: jest.fn(), + }, +})); + +jest.mock('expo-notifications', () => ({ + getLastNotificationResponseAsync: jest.fn(() => Promise.resolve(null)), + addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })), + addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })), +})); + +jest.mock('expo-linking', () => ({ + createURL: jest.fn((path: string) => `teachlink://${path}`), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + getInitialURL: jest.fn(() => Promise.resolve(null)), +})); + +beforeEach(() => { + jest.clearAllMocks(); + setDeepLinkAnalyticsHandler(null); +}); + +describe('linking config', () => { + describe('prefixes', () => { + it('includes the custom scheme and web URLs', () => { + expect(linking.prefixes).toContain('teachlink://'); + expect(linking.prefixes).toContain('https://teachlink.com'); + expect(linking.prefixes).toContain('https://www.teachlink.com'); + }); + }); + + describe('getInitialURL', () => { + it('returns null when no notification and no external URL', async () => { + (Notifications.getLastNotificationResponseAsync as jest.Mock).mockResolvedValue(null); + (Linking.getInitialURL as jest.Mock).mockResolvedValue(null); + + const url = await linking.getInitialURL!(); + expect(url).toBeNull(); + }); + + it('returns notification deep link when notification response exists', async () => { + (Notifications.getLastNotificationResponseAsync as jest.Mock).mockResolvedValue({ + notification: { + request: { + content: { + data: { + type: NotificationType.COURSE_UPDATE, + courseId: '456', + }, + }, + }, + }, + }); + + const url = await linking.getInitialURL!(); + expect(url).toBe('teachlink://course/456'); + }); + + it('returns external URL when no notification response', async () => { + (Notifications.getLastNotificationResponseAsync as jest.Mock).mockResolvedValue(null); + (Linking.getInitialURL as jest.Mock).mockResolvedValue('teachlink://messages/789'); + + const url = await linking.getInitialURL!(); + expect(url).toBe('teachlink://messages/789'); + }); + + it('handles malformed notification data gracefully', async () => { + (Notifications.getLastNotificationResponseAsync as jest.Mock).mockResolvedValue({ + notification: { + request: { + content: { + data: null, + }, + }, + }, + }); + (Linking.getInitialURL as jest.Mock).mockResolvedValue(null); + + const url = await linking.getInitialURL!(); + expect(url).toBeNull(); + }); + + it('handles notification with missing type field', async () => { + (Notifications.getLastNotificationResponseAsync as jest.Mock).mockResolvedValue({ + notification: { + request: { + content: { + data: { courseId: '999' }, + }, + }, + }, + }); + (Linking.getInitialURL as jest.Mock).mockResolvedValue(null); + + const url = await linking.getInitialURL!(); + expect(url).toBeNull(); + }); + }); + + describe('subscribe', () => { + it('returns a cleanup function', () => { + const cleanup = linking.subscribe!(jest.fn()); + expect(typeof cleanup).toBe('function'); + }); + + it('calls listener when linking event fires', () => { + const listener = jest.fn(); + const urlHandler = jest.fn(); + + (Linking.addEventListener as jest.Mock).mockImplementation((_event: string, handler: any) => { + urlHandler.mockImplementation(handler); + return { remove: jest.fn() }; + }); + + const cleanup = linking.subscribe!(listener); + urlHandler({ url: 'teachlink://course/123' }); + + expect(listener).toHaveBeenCalledWith('teachlink://course/123'); + cleanup(); + }); + + it('calls listener when notification response is received', () => { + const listener = jest.fn(); + const responseHandler = jest.fn(); + + (Notifications.addNotificationResponseReceivedListener as jest.Mock).mockImplementation( + (handler: any) => { + responseHandler.mockImplementation(handler); + return { remove: jest.fn() }; + } + ); + + const cleanup = linking.subscribe!(listener); + responseHandler({ + notification: { + request: { + content: { + data: { + type: NotificationType.MESSAGE, + conversationId: 'conv-42', + }, + }, + }, + }, + }); + + expect(listener).toHaveBeenCalledWith('teachlink://messages/conv-42'); + cleanup(); + }); + + it('calls listener for foreground notifications', () => { + const listener = jest.fn(); + const foregroundHandler = jest.fn(); + + (Notifications.addNotificationReceivedListener as jest.Mock).mockImplementation( + (handler: any) => { + foregroundHandler.mockImplementation(handler); + return { remove: jest.fn() }; + } + ); + + const cleanup = linking.subscribe!(listener); + foregroundHandler({ + request: { + content: { + data: { + type: NotificationType.LEARNING_REMINDER, + }, + }, + }, + } as any); + + expect(listener).toHaveBeenCalledWith('teachlink://learn'); + cleanup(); + }); + }); +}); + +describe('buildScreenUrl', () => { + it('builds URL for CourseDetail with courseId', () => { + const url = buildScreenUrl('CourseDetail', { courseId: '123' }); + expect(url).toBe('teachlink://course/123'); + }); + + it('builds URL for Chat with conversationId', () => { + const url = buildScreenUrl('Chat', { conversationId: 'conv-789' }); + expect(url).toBe('teachlink://messages/conv-789'); + }); + + it('builds URL for AchievementDetail', () => { + const url = buildScreenUrl('AchievementDetail', { achievementId: 'ach-1' }); + expect(url).toBe('teachlink://achievements/ach-1'); + }); + + it('builds URL for CommunityPost', () => { + const url = buildScreenUrl('CommunityPost', { postId: 'post-5' }); + expect(url).toBe('teachlink://community/post-5'); + }); + + it('builds URL for tab screens without params', () => { + expect(buildScreenUrl('Home')).toBe('teachlink://'); + expect(buildScreenUrl('Courses')).toBe('teachlink://courses'); + expect(buildScreenUrl('Messages')).toBe('teachlink://messages'); + expect(buildScreenUrl('Learning')).toBe('teachlink://learn'); + expect(buildScreenUrl('Community')).toBe('teachlink://community'); + expect(buildScreenUrl('Settings')).toBe('teachlink://settings'); + }); + + it('encodes special characters in params', () => { + const url = buildScreenUrl('CourseDetail', { courseId: 'a b&c=d' }); + expect(url).toBe('teachlink://course/a%20b%26c%3Dd'); + }); + + it('does not crash with XSS payload in params', () => { + const url = buildScreenUrl('CourseDetail', { courseId: '' }); + expect(url).toContain('teachlink://course/'); + expect(url).not.toContain('