feat(test): enhance test setup with comprehensive mocking

- Add proper TypeScript types for all mocks
- Implement UUID library mocking for unique token generation
- Add comprehensive fetch, FormData, Blob, and File API mocks
- Include crypto, performance, and observer API mocks
- Add utility functions for test helpers
- Improve console suppression for cleaner test output
- Fix localStorage and sessionStorage implementation
This commit is contained in:
William Valentin
2025-09-07 16:12:35 -07:00
parent b4a9318324
commit e7097ee102

View File

@@ -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<Record<string, unknown>>;
text: () => Promise<string>;
blob: () => Promise<Blob>;
arrayBuffer: () => Promise<ArrayBuffer>;
headers: Map<string, string>;
}
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<string, string> = {};
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<string, unknown> = { id: 'test-message-id' },
options: Partial<MockResponse> = {}
): 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<typeof fetch>;
// Mock FormData with proper implementation
class MockFormDataImpl implements MockFormData {
private data: Map<string, string | File> = 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<string> {
return this.data.keys();
}
values(): IterableIterator<string | File> {
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<ArrayBuffer> {
return Promise.resolve(new ArrayBuffer(this.size));
}
text(): Promise<string> {
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<ArrayBuffer> {
return Promise.resolve(new ArrayBuffer(this.size));
}
text(): Promise<string> {
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<Uint8Array<ArrayBuffer>> {
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<typeof fetch>).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<typeof fetch>).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<string, unknown> = {}
): Record<string, unknown> => ({
_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<void> =>
new Promise(resolve => setTimeout(resolve, ms)),
// Helper to wait for async operations in tests
waitFor: async (
callback: () => boolean | Promise<boolean>,
options: { timeout?: number; interval?: number } = {}
): Promise<void> => {
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;