562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
/**
|
|
* 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<string, unknown>
|
|
|
|
constructor(message: string, code: string, details?: Record<string, unknown>) {
|
|
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<typeof createMockLogger>
|
|
|
|
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<typeof createMockLogger>
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|