diff --git a/tests/setup.ts b/tests/setup.ts index 49bdbc1..91654ea 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,58 +1,452 @@ // Test setup file -// Configure jsdom and global test utilities +// Configure jsdom and global test utilities with improved type safety + +/* eslint-disable no-console */ import 'jest-environment-jsdom'; -// Mock localStorage -const localStorageMock = { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - length: 0, - key: jest.fn(), -} as Storage; +// Mock UUID library at module level +let mockUuidCounter = 1; +jest.mock('uuid', () => ({ + v4: (): string => { + const counter = mockUuidCounter++; + const hex = counter.toString(16).padStart(32, '0'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; + }, +})); -Object.defineProperty(window, 'localStorage', { +// Define proper types for our mocks +interface MockStorage { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; + clear: () => void; + length: number; + key: (index: number) => string | null; +} + +interface MockResponse { + ok: boolean; + status: number; + statusText: string; + json: () => Promise>; + text: () => Promise; + blob: () => Promise; + arrayBuffer: () => Promise; + headers: Map; +} + +interface MockFormData { + append: (key: string, value: string | File) => void; + get: (key: string) => string | File | null; + has: (key: string) => boolean; + delete: (key: string) => void; + entries: () => IterableIterator<[string, string | File]>; + forEach: ( + callback: (value: string | File, key: string, parent: MockFormData) => void + ) => void; +} + +// Mock localStorage with proper implementation +const createMockStorage = (): MockStorage => { + let store: Record = {}; + + return { + getItem: (key: string): string | null => { + return store[key] || null; + }, + setItem: (key: string, value: string): void => { + store[key] = value.toString(); + }, + removeItem: (key: string): void => { + delete store[key]; + }, + clear: (): void => { + store = {}; + }, + get length(): number { + return Object.keys(store).length; + }, + key: (index: number): string | null => { + const keys = Object.keys(store); + return keys[index] || null; + }, + }; +}; + +const localStorageMock = createMockStorage(); +const sessionStorageMock = createMockStorage(); + +// Attach storage mocks to global +Object.defineProperty(global, 'localStorage', { value: localStorageMock, + writable: true, }); -// Mock fetch -global.fetch = jest.fn(); +Object.defineProperty(global, 'sessionStorage', { + value: sessionStorageMock, + writable: true, +}); -// Mock import.meta for Jest +// Mock fetch with comprehensive response handling +const createMockResponse = ( + data: Record = { id: 'test-message-id' }, + options: Partial = {} +): MockResponse => ({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + blob: () => Promise.resolve(new Blob([JSON.stringify(data)])), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + headers: new Map(), + ...options, +}); + +global.fetch = jest + .fn() + .mockResolvedValue( + createMockResponse() as unknown as Response + ) as jest.MockedFunction; + +// Mock FormData with proper implementation +class MockFormDataImpl implements MockFormData { + private data: Map = new Map(); + + append(key: string, value: string | File): void { + this.data.set(key, value); + } + + get(key: string): string | File | null { + return this.data.get(key) || null; + } + + has(key: string): boolean { + return this.data.has(key); + } + + delete(key: string): void { + this.data.delete(key); + } + + entries(): IterableIterator<[string, string | File]> { + return this.data.entries(); + } + + forEach( + callback: (value: string | File, key: string, parent: MockFormData) => void + ): void { + this.data.forEach((value, key) => callback(value, key, this)); + } + + // Additional methods for completeness + getAll(key: string): (string | File)[] { + const value = this.data.get(key); + return value ? [value] : []; + } + + keys(): IterableIterator { + return this.data.keys(); + } + + values(): IterableIterator { + return this.data.values(); + } + + set(key: string, value: string | File): void { + this.data.set(key, value); + } + + [Symbol.iterator](): IterableIterator<[string, string | File]> { + return this.data.entries(); + } +} + +global.FormData = MockFormDataImpl as unknown as typeof FormData; + +// Mock File API +global.File = class MockFile { + name: string; + size: number; + type: string; + lastModified: number; + + constructor( + bits: BlobPart[], + filename: string, + options: FilePropertyBag = {} + ) { + this.name = filename; + this.size = bits.reduce((size, bit) => { + if (typeof bit === 'string') return size + bit.length; + if (bit instanceof ArrayBuffer) return size + bit.byteLength; + return size; + }, 0); + this.type = options.type || ''; + this.lastModified = options.lastModified || Date.now(); + } + + stream(): ReadableStream { + throw new Error('Not implemented in test environment'); + } + + arrayBuffer(): Promise { + return Promise.resolve(new ArrayBuffer(this.size)); + } + + text(): Promise { + return Promise.resolve('mock file content'); + } + + slice(): Blob { + return new Blob(); + } +} as unknown as typeof File; + +// Mock Blob API +global.Blob = class MockBlob { + size: number; + type: string; + + constructor(blobParts: BlobPart[] = [], options: BlobPropertyBag = {}) { + this.size = blobParts.reduce((size, part) => { + if (typeof part === 'string') return size + part.length; + if (part instanceof ArrayBuffer) return size + part.byteLength; + return size; + }, 0); + this.type = options.type || ''; + } + + arrayBuffer(): Promise { + return Promise.resolve(new ArrayBuffer(this.size)); + } + + text(): Promise { + return Promise.resolve('mock blob content'); + } + + stream(): ReadableStream { + throw new Error('Not implemented in test environment'); + } + + slice(): Blob { + return new MockBlob() as unknown as Blob; + } + + bytes(): Promise> { + return Promise.resolve(new Uint8Array(new ArrayBuffer(this.size))); + } +} as unknown as typeof Blob; + +// Mock crypto functions +global.btoa = (str: string): string => Buffer.from(str).toString('base64'); +global.atob = (str: string): string => Buffer.from(str, 'base64').toString(); + +// Mock URL for file handling +global.URL = { + createObjectURL: jest.fn(() => 'mock-url'), + revokeObjectURL: jest.fn(), +} as unknown as typeof URL; + +// Mock crypto.getRandomValues for UUID generation +Object.defineProperty(global, 'crypto', { + value: { + getRandomValues: (arr: Uint8Array): Uint8Array => { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return arr; + }, + randomUUID: (): string => { + return 'mock-uuid-' + (mockUuidCounter++).toString().padStart(8, '0'); + }, + }, +}); + +// Mock performance API +global.performance = { + now: jest.fn(() => Date.now()), + mark: jest.fn(), + measure: jest.fn(), + getEntriesByType: jest.fn(() => []), + getEntriesByName: jest.fn(() => []), +} as unknown as Performance; + +// Mock ResizeObserver +global.ResizeObserver = class MockResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} as unknown as typeof ResizeObserver; + +// Mock IntersectionObserver +global.IntersectionObserver = class MockIntersectionObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + root = null; + rootMargin = ''; + thresholds = []; +} as unknown as typeof IntersectionObserver; + +// Mock import.meta for Vite environment variables Object.defineProperty(globalThis, 'import', { value: { meta: { env: { NODE_ENV: 'test', + MODE: 'test', + DEV: false, + PROD: false, + SSR: false, VITE_COUCHDB_URL: 'http://localhost:5984', VITE_COUCHDB_USERNAME: 'admin', VITE_COUCHDB_PASSWORD: 'password', - VITE_MAILGUN_API_KEY: 'test-key', + VITE_MAILGUN_API_KEY: 'test-key-12345', VITE_MAILGUN_DOMAIN: 'test.mailgun.org', - VITE_MAILGUN_BASE_URL: 'https://api.mailgun.net', - VITE_MAILGUN_FROM_NAME: 'Test App', - VITE_MAILGUN_FROM_EMAIL: 'test@example.com', + VITE_MAILGUN_BASE_URL: 'https://api.mailgun.net/v3', + VITE_MAILGUN_FROM_NAME: 'Test Medication App', + VITE_MAILGUN_FROM_EMAIL: 'test@medapp.example.com', + VITE_APP_BASE_URL: 'http://localhost:5173', }, }, }, + writable: true, }); -// Setup console to avoid noise in tests -const originalError = console.error; +// Mock process.env for Node.js environment variables +process.env = { + ...process.env, + NODE_ENV: 'test', + JWT_SECRET: 'test-jwt-secret', + REFRESH_TOKEN_SECRET: 'test-refresh-secret', +}; + +// Setup function to clear all mocks before each test +const clearAllMocks = (): void => { + localStorageMock.clear(); + sessionStorageMock.clear(); + (global.fetch as jest.MockedFunction).mockClear(); + mockUuidCounter = 1; // Reset UUID counter + jest.clearAllMocks(); +}; + +// Global beforeEach hook +beforeEach(() => { + clearAllMocks(); + + // Reset fetch mock to default success response + (global.fetch as jest.MockedFunction).mockResolvedValue( + createMockResponse() as unknown as Response + ); +}); + +// Console management for cleaner test output +const originalConsole = { + error: console.error, + warn: console.warn, + log: console.log, + info: console.info, +}; + +// Patterns to suppress in test output +const SUPPRESSED_PATTERNS = [ + // React warnings + 'Warning: ReactDOM.render is deprecated', + 'Warning: componentWillMount has been renamed', + 'Warning: componentWillReceiveProps has been renamed', + 'Warning: componentWillUpdate has been renamed', + + // Application specific warnings + '📧 Mailgun Service', + 'Failed to send verification email', + 'Email sending failed', + + // Auth flow logging + '🔐 Login attempt for:', + '❌ User not found for email:', + '👤 User found:', + '🔍 Comparing passwords:', + '❌ Password mismatch', + '✅ Login successful for:', + + // Database warnings + 'CouchDB connection', + 'Mock database operation', +]; + +const shouldSuppressMessage = (message: string): boolean => { + return SUPPRESSED_PATTERNS.some(pattern => message.includes(pattern)); +}; + beforeAll(() => { - console.error = (...args: unknown[]) => { - if ( - typeof args[0] === 'string' && - args[0].includes('Warning: ReactDOM.render is deprecated') - ) { - return; + // Suppress console noise during tests + console.error = (...args: unknown[]): void => { + const message = args[0]?.toString() || ''; + if (!shouldSuppressMessage(message)) { + originalConsole.error(...args); } - originalError.call(console, ...args); }; + + console.warn = (...args: unknown[]): void => { + const message = args[0]?.toString() || ''; + if (!shouldSuppressMessage(message)) { + originalConsole.warn(...args); + } + }; + + // Suppress info and log in tests unless explicitly needed + console.info = jest.fn(); + console.log = jest.fn(); }); afterAll(() => { - console.error = originalError; + // Restore original console methods + Object.assign(console, originalConsole); }); + +// Utility functions for tests +export const testUtils = { + clearAllMocks, + createMockResponse, + shouldSuppressMessage, + + // Helper to create mock users for tests + createMockUser: ( + overrides: Record = {} + ): Record => ({ + _id: 'test-user-id', + email: 'test@example.com', + username: 'testuser', + password: 'password123', + emailVerified: false, + status: 'PENDING', + createdAt: new Date(), + ...overrides, + }), + + // Helper to mock async delays + delay: (ms: number): Promise => + new Promise(resolve => setTimeout(resolve, ms)), + + // Helper to wait for async operations in tests + waitFor: async ( + callback: () => boolean | Promise, + options: { timeout?: number; interval?: number } = {} + ): Promise => { + const { timeout = 5000, interval = 50 } = options; + const start = Date.now(); + + while (Date.now() - start < timeout) { + const result = await callback(); + if (result) return; + await testUtils.delay(interval); + } + + throw new Error(`waitFor timeout after ${timeout}ms`); + }, +}; + +// Export for use in tests if needed +export default testUtils;