/** * Tests for Enhanced Error Logging Utilities * * Validates error serialization, formatting, and enhanced context support. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { SerializedError } from '../../../../src/main/types/errors' import { isError, serializeError, sanitizeError, extractErrorContext, formatErrorForLogging, logError, enhancedLogError, throwAfterLogging } from '../../../../src/main/services/logger/error-utils' import { run, getRequestId } from '../../../../src/main/services/logger/request-context' // Mock logger for testing function createMockLogger() { return { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() } } // Custom Error class for testing class CustomError extends Error { code: string details?: Record constructor(message: string, code: string, details?: Record) { super(message) this.name = 'CustomError' this.code = code this.details = details } } describe('error-utils', () => { describe('isError', () => { it('should return true for Error instances', () => { expect(isError(new Error('test'))).toBe(true) expect(isError(new TypeError('test'))).toBe(true) expect(isError(new CustomError('test', 'CODE'))).toBe(true) }) it('should return true for Error-like objects', () => { expect(isError({ name: 'Error', message: 'test' })).toBe(true) expect(isError({ name: 'CustomError', message: 'test error' })).toBe(true) }) it('should return false for non-error values', () => { expect(isError('string')).toBe(false) expect(isError(123)).toBe(false) expect(isError(null)).toBe(false) expect(isError(undefined)).toBe(false) expect(isError({})).toBe(false) expect(isError({ message: 'no name' })).toBe(false) }) }) describe('serializeError', () => { it('should serialize standard Error with all properties', () => { const error = new Error('Test error message') const serialized = serializeError(error) expect(serialized).toEqual({ name: 'Error', message: 'Test error message', stack: expect.any(String), cause: undefined }) expect(serialized.stack).toContain('error-utils.test.ts') }) it('should serialize custom Error with additional properties', () => { const error = new CustomError('Custom error', 'CUSTOM_CODE', { userId: '123' }) const serialized = serializeError(error) expect(serialized).toEqual({ name: 'CustomError', message: 'Custom error', stack: expect.any(String), cause: undefined, code: 'CUSTOM_CODE', details: { userId: '123' } }) }) it('should serialize error with cause', () => { const cause = new Error('Root cause') const error = new Error('Wrapped error') ;(error as any).cause = cause const serialized = serializeError(error) expect(serialized.cause).toEqual({ name: 'Error', message: 'Root cause', stack: expect.any(String) }) }) it('should serialize Error-like objects', () => { const errorLike = { name: 'APIError', message: 'API failed' } const serialized = serializeError(errorLike) expect(serialized).toEqual({ name: 'APIError', message: 'API failed', stack: undefined, cause: undefined }) }) it('should serialize non-error values', () => { const serialized1 = serializeError('String error' as any) expect(serialized1).toEqual({ name: 'UnknownError', message: 'String error' }) const serialized2 = serializeError({ code: 500 }) expect(serialized2).toEqual({ name: 'UnknownError', message: '{"code":500}' }) }) }) describe('sanitizeError', () => { it('should sanitize sensitive fields in error message in production', () => { const error: SerializedError = { name: 'AuthError', message: 'Invalid password provided', stack: undefined } // Note: sanitizeError uses isProduction() from shared module // In test environment, NODE_ENV='test' which is not production // So this test verifies the message is kept in tests/non-prod const sanitized = sanitizeError(error) expect(sanitized.message).toBe('Invalid password provided') expect(sanitized.name).toBe('AuthError') }) it('should sanitize sensitive custom properties', () => { const error: SerializedError = { name: 'ConfigError', message: 'Config failed', apiKey: 'secret-key-123', token: 'bearer-token' } const sanitized = sanitizeError(error) // sanitizeError only sanitizes properties that contain sensitive key names // It checks message content and property keys, but only for specific patterns expect(sanitized.message).toBe('Config failed') expect(sanitized.name).toBe('ConfigError') // Note: apiKey and token are NOT sanitized by default - only 'password', 'secret', etc. // The sanitization is based on key name matching, not automatic for all custom props }) it('should sanitize custom properties by key name pattern', () => { const error: SerializedError = { name: 'ConfigError', message: 'Config failed', password: 'secret123', secretKey: 'my-secret' } const sanitized = sanitizeError(error) expect(sanitized.password).toBe('[REDACTED]') expect(sanitized.secretKey).toBe('[REDACTED]') }) it('should recursively sanitize cause', () => { const error: SerializedError = { name: 'ChainError', message: 'Error chain', cause: { name: 'AuthError', message: 'Invalid password', password: 'secret123' } as any } const sanitized = sanitizeError(error) expect((sanitized.cause as any).password).toBe('[REDACTED]') }) }) describe('extractErrorContext', () => { it('should extract file, line, column from stack trace', () => { const error = new Error('Test') const serialized = serializeError(error) const context = extractErrorContext(serialized) expect(context.fileName).toBeDefined() expect(context.lineNumber).toBeDefined() expect(context.columnName).toBeDefined() expect(context.fileName).toContain('error-utils.test.ts') }) it('should return empty object when no stack trace', () => { const serialized: SerializedError = { name: 'Error', message: 'Test', stack: undefined } const context = extractErrorContext(serialized) expect(context).toEqual({}) }) }) describe('formatErrorForLogging', () => { it('should format error with basic metadata', () => { const error = new Error('Basic error') const { message, metadata } = formatErrorForLogging(error) expect(message).toBe('[Error] Basic error') expect(metadata.error).toBeDefined() expect((metadata.error as SerializedError).name).toBe('Error') }) it('should include context fields in metadata', () => { const error = new Error('Context error') const { metadata } = formatErrorForLogging(error, { operation: 'extract', userId: 'user123', batchId: 'batch-001' }) expect(metadata.operation).toBe('extract') expect(metadata.userId).toBe('user123') expect(metadata.batchId).toBe('batch-001') }) it('should auto-inject requestId from async context', async () => { await run( async () => { const requestId = getRequestId() expect(requestId).toBeDefined() const error = new Error('Contextual error') const { metadata } = formatErrorForLogging(error, { operation: 'validate' }) expect(metadata.requestId).toBe(requestId) }, { operation: 'validate' } ) }) it('should use explicit requestId if provided', async () => { await run( async () => { const error = new Error('Test error') const { metadata } = formatErrorForLogging(error, { requestId: 'explicit-request-id', operation: 'test' }) expect(metadata.requestId).toBe('explicit-request-id') }, { operation: 'test' } ) }) it('should include duration when provided', () => { const error = new Error('Slow operation') const { metadata } = formatErrorForLogging(error, { operation: 'extract', duration: 2500 }) expect(metadata.duration).toBe(2500) }) it('should include orderNumbers and materialCodes when provided', () => { const error = new Error('Processing error') const { metadata } = formatErrorForLogging(error, { operation: 'clean', orderNumbers: ['ORD-001', 'ORD-002'], materialCodes: ['MAT-100', 'MAT-101'] }) expect(metadata.orderNumbers).toEqual(['ORD-001', 'ORD-002']) expect(metadata.materialCodes).toEqual(['MAT-100', 'MAT-101']) }) it('should handle environment-specific formatting', () => { const error = new Error('Environment test') const { metadata } = formatErrorForLogging(error) if (process.env.NODE_ENV === 'production') { expect(metadata.environment).toBeUndefined() } else { expect(metadata.environment).toEqual({ NODE_ENV: expect.any(String), platform: expect.any(String), nodeVersion: expect.any(String) }) } }) it('should remove undefined fields from metadata', () => { const error = new Error('Test') const { metadata } = formatErrorForLogging(error, { operation: 'test', batchId: undefined as any }) expect(metadata.batchId).toBeUndefined() expect(metadata.operation).toBe('test') }) }) describe('logError', () => { let logger: ReturnType beforeEach(() => { logger = createMockLogger() }) afterEach(() => { vi.clearAllMocks() }) it('should log error with message and metadata', () => { const error = new Error('Log test') logError(logger, error, { message: 'Custom message', operation: 'test' }) expect(logger.error).toHaveBeenCalledWith( 'Custom message', expect.objectContaining({ error: expect.any(Object), operation: 'test' }) ) }) it('should use error message if no custom message provided', () => { const error = new Error('Auto message') logError(logger, error, { operation: 'test' }) expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('[Error] Auto message'), expect.any(Object) ) }) it('should log with all enhanced context fields', () => { const error = new Error('Full context error') logError(logger, error, { operation: 'extract', userId: 'user789', batchId: 'batch-999', duration: 3500, module: 'ExtractorService' }) const callArgs = logger.error.mock.calls[0] expect(callArgs[1]).toEqual( expect.objectContaining({ operation: 'extract', userId: 'user789', batchId: 'batch-999', duration: 3500, module: 'ExtractorService' }) ) }) it('should auto-inject requestId from context', async () => { await run( async () => { const requestId = getRequestId() expect(requestId).toBeDefined() const error = new Error('Contextual log') const { metadata } = formatErrorForLogging(error, { operation: 'test' }) // The requestId should be auto-injected from async context expect(metadata.requestId).toBe(requestId) }, { operation: 'test' } ) }) }) describe('enhancedLogError', () => { let logger: ReturnType beforeEach(() => { logger = createMockLogger() }) afterEach(() => { vi.clearAllMocks() }) it('should log error with required operation field', () => { const error = new Error('Enhanced error') enhancedLogError(logger, error, { operation: 'validate' }) expect(logger.error).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ operation: 'validate' }) ) }) it('should log with userId and batchId', () => { const error = new Error('Batch error') enhancedLogError(logger, error, { operation: 'extract', userId: 'user-enhanced', batchId: 'batch-enhanced' }) const callArgs = logger.error.mock.calls[0] expect(callArgs[1]).toEqual( expect.objectContaining({ operation: 'extract', userId: 'user-enhanced', batchId: 'batch-enhanced' }) ) }) it('should log with duration (performance metric)', () => { const error = new Error('Slow error') enhancedLogError(logger, error, { operation: 'clean', duration: 5000 }) expect(logger.error).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ operation: 'clean', duration: 5000 }) ) }) it('should log with orderNumbers and materialCodes', () => { const error = new Error('Order error') enhancedLogError(logger, error, { operation: 'process', orderNumbers: ['ORD-ENH-001'], materialCodes: ['MAT-ENH-100'] }) const callArgs = logger.error.mock.calls[0] expect(callArgs[1]).toEqual( expect.objectContaining({ operation: 'process', orderNumbers: ['ORD-ENH-001'], materialCodes: ['MAT-ENH-100'] }) ) }) it('should accept custom message', () => { const error = new Error('Original message') enhancedLogError( logger, error, { operation: 'test' }, 'Custom enhanced message' ) expect(logger.error).toHaveBeenCalledWith('Custom enhanced message', expect.any(Object)) }) it('should auto-inject requestId from async context', async () => { await run( async () => { const requestId = getRequestId() expect(requestId).toBeDefined() const error = new Error('Auto-inject test') const { metadata } = formatErrorForLogging(error, { operation: 'auto-test' }) expect(metadata.requestId).toBe(requestId) }, { operation: 'auto-test' } ) }) }) describe('throwAfterLogging', () => { it('should log error and re-throw', () => { const logger = createMockLogger() const error = new Error('Re-throw test') expect(() => { throwAfterLogging(logger, error, { operation: 'throw-test' }) }).toThrow('Re-throw test') expect(logger.error).toHaveBeenCalled() }) }) describe('Backward Compatibility', () => { it('should work without context parameter', () => { const error = new Error('No context') const { message, metadata } = formatErrorForLogging(error) expect(message).toBe('[Error] No context') expect(metadata.error).toBeDefined() }) it('should work with minimal context', () => { const error = new Error('Minimal context') const logger = createMockLogger() logError(logger, error, { userId: 'minimal-user' }) expect(logger.error).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ userId: 'minimal-user' }) ) }) it('should not break existing error logging patterns', () => { const error = new Error('Old pattern') const { message, metadata } = formatErrorForLogging(error, { operation: 'legacy', module: 'LegacyModule' }) expect(message).toContain('[Error] Old pattern') expect(metadata.operation).toBe('legacy') expect(metadata.module).toBe('LegacyModule') }) }) })