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:
450
tests/setup.ts
450
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<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;
|
||||
|
||||
Reference in New Issue
Block a user