feat(logging-p0): complete Wave 2 - Auth/Extractor/Cleaner services transformed
This commit is contained in:
381
tests/integration/logger-performance.test.ts
Normal file
381
tests/integration/logger-performance.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Performance Monitor Unit Tests
|
||||
*
|
||||
* Tests for trackDuration helper and PerformanceTracker class
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import {
|
||||
trackDuration,
|
||||
PerformanceTracker,
|
||||
createPerformanceTracker,
|
||||
DEFAULT_SLOW_THRESHOLD_MS,
|
||||
type TrackDurationOptions
|
||||
} from '../../src/main/services/logger/performance-monitor'
|
||||
import logger from '../../src/main/services/logger/index'
|
||||
|
||||
// Mock the logger to avoid noisy output during tests
|
||||
vi.mock('../../src/main/services/logger/index', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Performance Monitor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('trackDuration', () => {
|
||||
it('should track duration of successful async operation', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('test result')
|
||||
|
||||
const result = await trackDuration(mockFn, {
|
||||
operationName: 'TestOperation'
|
||||
})
|
||||
|
||||
expect(result.result).toBe('test result')
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0)
|
||||
expect(result.isSlow).toBe(false)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should mark operation as slow when exceeding threshold', async () => {
|
||||
const slowFn = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('slow'), 50)))
|
||||
|
||||
const result = await trackDuration(slowFn, {
|
||||
operationName: 'SlowOperation',
|
||||
slowThresholdMs: 10
|
||||
})
|
||||
|
||||
expect(result.result).toBe('slow')
|
||||
expect(result.durationMs).toBeGreaterThan(10)
|
||||
expect(result.isSlow).toBe(true)
|
||||
})
|
||||
|
||||
it('should log with custom log level', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('result')
|
||||
|
||||
await trackDuration(mockFn, {
|
||||
operationName: 'CustomLevelOp',
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
expect(logger.info).toHaveBeenCalled()
|
||||
expect(logger.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log warning for slow operations', async () => {
|
||||
const slowFn = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('slow'), 100)))
|
||||
|
||||
await trackDuration(slowFn, {
|
||||
operationName: 'SlowOp',
|
||||
slowThresholdMs: 50
|
||||
})
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled()
|
||||
const warnCall = (logger.warn as Mock).mock.calls[0][0]
|
||||
expect(warnCall).toContain('SLOW')
|
||||
expect(warnCall).toContain('50ms threshold')
|
||||
})
|
||||
|
||||
it('should include context in logs', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('result')
|
||||
|
||||
await trackDuration(mockFn, {
|
||||
operationName: 'ContextOp',
|
||||
context: { userId: 123, customField: 'test' }
|
||||
})
|
||||
|
||||
expect(logger.debug).toHaveBeenCalled()
|
||||
const contextCall = (logger.debug as Mock).mock.calls[0][1]
|
||||
expect(contextCall).toMatchObject({
|
||||
operation: 'ContextOp',
|
||||
userId: 123
|
||||
})
|
||||
// Also check the custom field is included
|
||||
expect(contextCall.customField).toBe('test')
|
||||
})
|
||||
|
||||
it('should log error and duration when operation fails', async () => {
|
||||
const errorFn = vi.fn().mockRejectedValue(new Error('Test error'))
|
||||
|
||||
await expect(
|
||||
trackDuration(errorFn, {
|
||||
operationName: 'ErrorOp'
|
||||
})
|
||||
).rejects.toThrow('Test error')
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
const errorCall = (logger.error as Mock).mock.calls[0][0]
|
||||
expect(errorCall).toContain('failed after')
|
||||
expect(errorCall).toContain('ErrorOp')
|
||||
})
|
||||
|
||||
it('should use custom message in logs', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('result')
|
||||
|
||||
await trackDuration(mockFn, {
|
||||
operationName: 'TestOp',
|
||||
message: 'Custom message for this operation'
|
||||
})
|
||||
|
||||
expect(logger.debug).toHaveBeenCalled()
|
||||
const messageCall = (logger.debug as Mock).mock.calls[0][0]
|
||||
expect(messageCall).toContain('Custom message for this operation')
|
||||
})
|
||||
|
||||
it('should have default threshold of 1000ms', async () => {
|
||||
const slowFn = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('slow'), 100)))
|
||||
|
||||
const result = await trackDuration(slowFn, {
|
||||
operationName: 'DefaultThresholdOp'
|
||||
})
|
||||
|
||||
// 100ms should NOT be slow with default 1000ms threshold
|
||||
expect(result.isSlow).toBe(false)
|
||||
expect(result.durationMs).toBeLessThan(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PerformanceTracker', () => {
|
||||
it('should track multiple operations', async () => {
|
||||
const tracker = new PerformanceTracker('TestService', 1000)
|
||||
|
||||
const mockFn1 = vi.fn().mockResolvedValue('result1')
|
||||
const mockFn2 = vi.fn().mockResolvedValue('result2')
|
||||
|
||||
const result1 = await tracker.track('Operation1', mockFn1)
|
||||
const result2 = await tracker.track('Operation2', mockFn2)
|
||||
|
||||
expect(result1).toBe('result1')
|
||||
expect(result2).toBe('result2')
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
expect(metrics.count).toBe(2)
|
||||
expect(metrics.minDurationMs).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.maxDurationMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should calculate correct metrics', async () => {
|
||||
const tracker = new PerformanceTracker('TestService', 1000)
|
||||
|
||||
// Track operations with known durations
|
||||
tracker.recordDuration(100)
|
||||
tracker.recordDuration(200)
|
||||
tracker.recordDuration(300)
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
|
||||
expect(metrics.count).toBe(3)
|
||||
expect(metrics.totalDurationMs).toBe(600)
|
||||
expect(metrics.minDurationMs).toBe(100)
|
||||
expect(metrics.maxDurationMs).toBe(300)
|
||||
expect(metrics.avgDurationMs).toBe(200)
|
||||
})
|
||||
|
||||
it('should track slow operations count', async () => {
|
||||
const tracker = new PerformanceTracker('TestService', 50)
|
||||
|
||||
tracker.recordDuration(30) // Normal
|
||||
tracker.recordDuration(100) // Slow
|
||||
tracker.recordDuration(40) // Normal
|
||||
tracker.recordDuration(150) // Slow
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
expect(metrics.slowOperationCount).toBe(2)
|
||||
})
|
||||
|
||||
it('should log warnings for slow operations', async () => {
|
||||
const tracker = new PerformanceTracker('TestService', 10)
|
||||
|
||||
const slowFn = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('slow'), 50)))
|
||||
|
||||
await tracker.track('SlowOp', slowFn)
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled()
|
||||
const warnCall = (logger.warn as Mock).mock.calls[0][0]
|
||||
expect(warnCall).toContain('[TestService]')
|
||||
expect(warnCall).toContain('SLOW')
|
||||
})
|
||||
|
||||
it('should include context in operation logs', async () => {
|
||||
const tracker = new PerformanceTracker('DatabaseService', 1000)
|
||||
|
||||
const mockFn = vi.fn().mockResolvedValue('data')
|
||||
|
||||
await tracker.track('getUser', mockFn, { userId: 456, table: 'users' })
|
||||
|
||||
expect(logger.debug).toHaveBeenCalled()
|
||||
const contextCall = (logger.debug as Mock).mock.calls[0][1]
|
||||
expect(contextCall).toMatchObject({
|
||||
operation: 'getUser',
|
||||
userId: 456
|
||||
})
|
||||
})
|
||||
|
||||
it('should log summary with aggregated metrics', () => {
|
||||
const tracker = new PerformanceTracker('TestService', 1000)
|
||||
|
||||
tracker.recordDuration(100)
|
||||
tracker.recordDuration(200)
|
||||
tracker.recordDuration(300)
|
||||
|
||||
tracker.logSummary('info', 'Test Summary')
|
||||
|
||||
expect(logger.info).toHaveBeenCalled()
|
||||
const summaryCall = (logger.info as Mock).mock.calls[0]
|
||||
expect(summaryCall[0]).toContain('Test Summary')
|
||||
expect(summaryCall[1]).toMatchObject({
|
||||
totalOperations: 3,
|
||||
slowOperations: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('should include slow percentage in summary', () => {
|
||||
const tracker = new PerformanceTracker('TestService', 50)
|
||||
|
||||
tracker.recordDuration(30) // Normal
|
||||
tracker.recordDuration(100) // Slow
|
||||
|
||||
tracker.logSummary()
|
||||
|
||||
expect(logger.info).toHaveBeenCalled()
|
||||
const summaryCall = (logger.info as Mock).mock.calls[0][1]
|
||||
expect(summaryCall.slowPercentage).toContain('%')
|
||||
})
|
||||
|
||||
it('should reset metrics when reset() is called', () => {
|
||||
const tracker = new PerformanceTracker('TestService', 1000)
|
||||
|
||||
tracker.recordDuration(100)
|
||||
tracker.recordDuration(200)
|
||||
|
||||
tracker.reset()
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
expect(metrics.count).toBe(0)
|
||||
expect(metrics.totalDurationMs).toBe(0)
|
||||
expect(metrics.slowOperationCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should return zero metrics when no operations tracked', () => {
|
||||
const tracker = new PerformanceTracker('EmptyService')
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
|
||||
expect(metrics.count).toBe(0)
|
||||
expect(metrics.totalDurationMs).toBe(0)
|
||||
expect(metrics.minDurationMs).toBe(0)
|
||||
expect(metrics.maxDurationMs).toBe(0)
|
||||
expect(metrics.avgDurationMs).toBe(0)
|
||||
expect(metrics.slowOperationCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should use custom logger if provided', () => {
|
||||
const customLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
} as unknown as typeof logger
|
||||
|
||||
const tracker = new PerformanceTracker('TestService', 1000, customLogger)
|
||||
tracker.recordDuration(100)
|
||||
|
||||
// Should use custom logger
|
||||
expect(customLogger.debug).not.toHaveBeenCalled() // We called recordDuration directly
|
||||
tracker.logSummary()
|
||||
expect(customLogger.info).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPerformanceTracker', () => {
|
||||
it('should create a tracker with default threshold', () => {
|
||||
const tracker = createPerformanceTracker('MyService')
|
||||
|
||||
expect(tracker).toBeInstanceOf(PerformanceTracker)
|
||||
const metrics = tracker.getMetrics()
|
||||
expect(metrics.count).toBe(0)
|
||||
})
|
||||
|
||||
it('should create a tracker with custom threshold', () => {
|
||||
const tracker = createPerformanceTracker('FastService', 100)
|
||||
|
||||
tracker.recordDuration(150)
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
expect(metrics.slowOperationCount).toBe(1) // 150 > 100
|
||||
})
|
||||
|
||||
it('should use operation name in logs', async () => {
|
||||
const tracker = createPerformanceTracker('MyCustomService', 1000)
|
||||
|
||||
const mockFn = vi.fn().mockResolvedValue('result')
|
||||
await tracker.track('TestOperation', mockFn)
|
||||
|
||||
expect(logger.debug).toHaveBeenCalled()
|
||||
const call = (logger.debug as Mock).mock.calls[0][0]
|
||||
expect(call).toContain('[MyCustomService]')
|
||||
expect(call).toContain('TestOperation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should track a sequence of operations with varying speeds', async () => {
|
||||
const tracker = new PerformanceTracker('DataPipeline', 100)
|
||||
|
||||
const fastOp = vi.fn().mockResolvedValue('fast')
|
||||
const mediumOp = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('medium'), 50)))
|
||||
const slowOp = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('slow'), 200)))
|
||||
|
||||
await tracker.track('FastExtract', fastOp)
|
||||
await tracker.track('MediumTransform', mediumOp)
|
||||
await tracker.track('SlowLoad', slowOp)
|
||||
|
||||
const metrics = tracker.getMetrics()
|
||||
expect(metrics.count).toBe(3)
|
||||
expect(metrics.slowOperationCount).toBe(1) // Only SlowLoad > 100ms
|
||||
expect(metrics.maxDurationMs).toBeGreaterThan(150)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully in tracker', async () => {
|
||||
const tracker = new PerformanceTracker('ErrorProneService', 1000)
|
||||
|
||||
const errorFn = vi.fn().mockRejectedValue(new Error('Expected error'))
|
||||
|
||||
await expect(tracker.track('FailingOp', errorFn)).rejects.toThrow('Expected error')
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
const errorCall = (logger.error as Mock).mock.calls[0][0]
|
||||
expect(errorCall).toContain('[ErrorProneService]')
|
||||
expect(errorCall).toContain('failed after')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constants', () => {
|
||||
it('should export DEFAULT_SLOW_THRESHOLD_MS as 1000', () => {
|
||||
expect(DEFAULT_SLOW_THRESHOLD_MS).toBe(1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
54
tests/unit/logger-integration.test.ts
Normal file
54
tests/unit/logger-integration.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Logger Integration Tests - RequestContext Integration
|
||||
* Verifies RequestContext is properly integrated with Logger
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('Logger RequestContext Integration', () => {
|
||||
it('should export run from request-context', async () => {
|
||||
const { run } = await import('../../src/main/services/logger/index')
|
||||
expect(run).toBeDefined()
|
||||
expect(typeof run).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getRequestId from request-context', async () => {
|
||||
const { getRequestId } = await import('../../src/main/services/logger/index')
|
||||
expect(getRequestId).toBeDefined()
|
||||
expect(typeof getRequestId).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getContext from request-context', async () => {
|
||||
const { getContext } = await import('../../src/main/services/logger/index')
|
||||
expect(getContext).toBeDefined()
|
||||
expect(typeof getContext).toBe('function')
|
||||
})
|
||||
|
||||
it('should export withContext from request-context', async () => {
|
||||
const { withContext } = await import('../../src/main/services/logger/index')
|
||||
expect(withContext).toBeDefined()
|
||||
expect(typeof withContext).toBe('function')
|
||||
})
|
||||
|
||||
it('should export withRequestContext wrapper', async () => {
|
||||
const { withRequestContext } = await import('../../src/main/services/logger/index')
|
||||
expect(withRequestContext).toBeDefined()
|
||||
expect(typeof withRequestContext).toBe('function')
|
||||
})
|
||||
|
||||
it('should export createLogger', async () => {
|
||||
const { createLogger } = await import('../../src/main/services/logger/index')
|
||||
expect(createLogger).toBeDefined()
|
||||
expect(typeof createLogger).toBe('function')
|
||||
})
|
||||
|
||||
it('should have all exports available from LoggerContext type', async () => {
|
||||
const loggerModule = await import('../../src/main/services/logger/index')
|
||||
expect(loggerModule.run).toBeDefined()
|
||||
expect(loggerModule.getRequestId).toBeDefined()
|
||||
expect(loggerModule.getContext).toBeDefined()
|
||||
expect(loggerModule.withContext).toBeDefined()
|
||||
expect(loggerModule.withRequestContext).toBeDefined()
|
||||
expect(loggerModule.createLogger).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -48,34 +48,43 @@ vi.mock('winston', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const formatFn = vi.fn((fn: any) => fn && fn()) as any
|
||||
formatFn.combine = vi.fn((...args) => args)
|
||||
formatFn.timestamp = vi.fn(() => ({ type: 'timestamp' }))
|
||||
formatFn.colorize = vi.fn(() => ({ type: 'colorize' }))
|
||||
formatFn.printf = vi.fn((fn: any) => fn)
|
||||
formatFn.json = vi.fn(() => ({ type: 'json' }))
|
||||
|
||||
return {
|
||||
default: {
|
||||
createLogger: vi.fn(() => createLoggerInstance),
|
||||
format: {
|
||||
combine: vi.fn((...args) => args),
|
||||
timestamp: vi.fn(() => ({ type: 'timestamp' })),
|
||||
colorize: vi.fn(() => ({ type: 'colorize' })),
|
||||
printf: vi.fn((fn) => fn),
|
||||
json: vi.fn(() => ({ type: 'json' }))
|
||||
},
|
||||
format: formatFn,
|
||||
transports: {
|
||||
Console: vi.fn()
|
||||
Console: vi.fn() as any,
|
||||
DailyRotateFile: vi.fn() as any
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('winston-daily-rotate-file', () => ({
|
||||
default: vi.fn()
|
||||
default: vi.fn() as any
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isReady: vi.fn(() => false),
|
||||
getPath: vi.fn(() => './logs'),
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
vi.mock(
|
||||
'electron',
|
||||
() =>
|
||||
({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(() => [])
|
||||
},
|
||||
app: {
|
||||
isReady: vi.fn(() => false),
|
||||
getPath: vi.fn(() => './logs'),
|
||||
isPackaged: false
|
||||
}
|
||||
}) as any
|
||||
)
|
||||
|
||||
describe('Logger', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
432
tests/unit/request-context.test.ts
Normal file
432
tests/unit/request-context.test.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* RequestContext Unit Tests
|
||||
*
|
||||
* Tests for AsyncLocalStorage-based request context management:
|
||||
* - Context propagation across async/await
|
||||
* - Concurrent request isolation
|
||||
* - Nested contexts
|
||||
* - Non-request scenarios
|
||||
* - userId and operation fields
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
run,
|
||||
getRequestId,
|
||||
getContext,
|
||||
withContext,
|
||||
LoggerContext
|
||||
} from '../../src/main/services/logger/request-context'
|
||||
|
||||
describe('RequestContext', () => {
|
||||
beforeEach(() => {
|
||||
// Clear any existing context before each test
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Context is automatically cleaned up when async scope exits
|
||||
})
|
||||
|
||||
describe('run()', () => {
|
||||
it('should create context with auto-generated requestId', async () => {
|
||||
let capturedRequestId: string | undefined
|
||||
|
||||
await run(async () => {
|
||||
capturedRequestId = getRequestId()
|
||||
})
|
||||
|
||||
expect(capturedRequestId).toBeDefined()
|
||||
expect(typeof capturedRequestId).toBe('string')
|
||||
// UUID v4 format check (basic)
|
||||
expect(capturedRequestId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
)
|
||||
})
|
||||
|
||||
it('should include userId when provided', async () => {
|
||||
let context: LoggerContext | undefined
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
context = getContext()
|
||||
},
|
||||
{ userId: 'user-123' }
|
||||
)
|
||||
|
||||
expect(context).toBeDefined()
|
||||
expect(context?.userId).toBe('user-123')
|
||||
expect(context?.requestId).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include operation when provided', async () => {
|
||||
let context: LoggerContext | undefined
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
context = getContext()
|
||||
},
|
||||
{ operation: 'extract' }
|
||||
)
|
||||
|
||||
expect(context).toBeDefined()
|
||||
expect(context?.operation).toBe('extract')
|
||||
expect(context?.requestId).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include both userId and operation when provided', async () => {
|
||||
let context: LoggerContext | undefined
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
context = getContext()
|
||||
},
|
||||
{ userId: 'user-456', operation: 'clean' }
|
||||
)
|
||||
|
||||
expect(context).toBeDefined()
|
||||
expect(context?.userId).toBe('user-456')
|
||||
expect(context?.operation).toBe('clean')
|
||||
expect(context?.requestId).toBeDefined()
|
||||
})
|
||||
|
||||
it('should work without optional context', async () => {
|
||||
let context: LoggerContext | undefined
|
||||
|
||||
await run(async () => {
|
||||
context = getContext()
|
||||
})
|
||||
|
||||
expect(context).toBeDefined()
|
||||
expect(context?.requestId).toBeDefined()
|
||||
expect(context?.userId).toBeUndefined()
|
||||
expect(context?.operation).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Context Propagation', () => {
|
||||
it('should propagate context across async/await', async () => {
|
||||
const requestIds: (string | undefined)[] = []
|
||||
|
||||
async function nestedOperation() {
|
||||
requestIds.push(getRequestId())
|
||||
await Promise.resolve() // Simulate async operation
|
||||
requestIds.push(getRequestId())
|
||||
}
|
||||
|
||||
await run(async () => {
|
||||
requestIds.push(getRequestId())
|
||||
await nestedOperation()
|
||||
requestIds.push(getRequestId())
|
||||
})
|
||||
|
||||
// All should have the same requestId
|
||||
expect(requestIds).toHaveLength(4)
|
||||
expect(new Set(requestIds).size).toBe(1)
|
||||
expect(requestIds[0]).toBeDefined()
|
||||
})
|
||||
|
||||
it('should propagate context through Promise.all', async () => {
|
||||
const requestIds: (string | undefined)[] = []
|
||||
|
||||
await run(async () => {
|
||||
const outerId = getRequestId()
|
||||
requestIds.push(outerId)
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
requestIds.push(getRequestId())
|
||||
await Promise.resolve()
|
||||
requestIds.push(getRequestId())
|
||||
})(),
|
||||
(async () => {
|
||||
requestIds.push(getRequestId())
|
||||
await Promise.resolve()
|
||||
requestIds.push(getRequestId())
|
||||
})()
|
||||
])
|
||||
|
||||
requestIds.push(getRequestId())
|
||||
})
|
||||
|
||||
// All should have the same requestId (6 total: 1 outer + 2 from each parallel + 1 final)
|
||||
expect(requestIds).toHaveLength(6)
|
||||
expect(new Set(requestIds).size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Concurrent Request Isolation', () => {
|
||||
it('should maintain separate contexts for concurrent requests', async () => {
|
||||
const request1Ids: (string | undefined)[] = []
|
||||
const request2Ids: (string | undefined)[] = []
|
||||
|
||||
const promise1 = run(
|
||||
async () => {
|
||||
request1Ids.push(getRequestId())
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
request1Ids.push(getRequestId())
|
||||
},
|
||||
{ userId: 'user-1', operation: 'extract' }
|
||||
)
|
||||
|
||||
const promise2 = run(
|
||||
async () => {
|
||||
request2Ids.push(getRequestId())
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
request2Ids.push(getRequestId())
|
||||
},
|
||||
{ userId: 'user-2', operation: 'clean' }
|
||||
)
|
||||
|
||||
await Promise.all([promise1, promise2])
|
||||
|
||||
// Each request should have consistent internal context
|
||||
expect(request1Ids).toHaveLength(2)
|
||||
expect(request2Ids).toHaveLength(2)
|
||||
|
||||
// promise1 should be consistent
|
||||
expect(request1Ids[0]).toBe(request1Ids[1])
|
||||
// promise2 should be consistent
|
||||
expect(request2Ids[0]).toBe(request2Ids[1])
|
||||
// Different requests should have different requestIds
|
||||
expect(request1Ids[0]).not.toBe(request2Ids[0])
|
||||
})
|
||||
|
||||
it('should not leak context between sequential requests', async () => {
|
||||
let firstRequestId: string | undefined
|
||||
let secondRequestId: string | undefined
|
||||
|
||||
// First request
|
||||
await run(
|
||||
async () => {
|
||||
firstRequestId = getRequestId()
|
||||
},
|
||||
{ userId: 'first-user' }
|
||||
)
|
||||
|
||||
// Second request (should have new context)
|
||||
await run(
|
||||
async () => {
|
||||
secondRequestId = getRequestId()
|
||||
},
|
||||
{ userId: 'second-user' }
|
||||
)
|
||||
|
||||
expect(firstRequestId).toBeDefined()
|
||||
expect(secondRequestId).toBeDefined()
|
||||
expect(firstRequestId).not.toBe(secondRequestId)
|
||||
|
||||
// Outside any context, should be undefined
|
||||
expect(getRequestId()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nested Contexts', () => {
|
||||
it('should support nesting without affecting outer context', async () => {
|
||||
const requestIds: { outer: string | undefined; inner: string | undefined } = {
|
||||
outer: undefined,
|
||||
inner: undefined
|
||||
}
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
requestIds.outer = getRequestId()
|
||||
|
||||
await run(async () => {
|
||||
requestIds.inner = getRequestId()
|
||||
})
|
||||
|
||||
// Outer context should be unchanged after nesting
|
||||
expect(getRequestId()).toBe(requestIds.outer)
|
||||
},
|
||||
{ operation: 'outer' }
|
||||
)
|
||||
|
||||
// Both should be defined but different
|
||||
expect(requestIds.outer).toBeDefined()
|
||||
expect(requestIds.inner).toBeDefined()
|
||||
expect(requestIds.outer).not.toBe(requestIds.inner)
|
||||
})
|
||||
|
||||
it('should restore outer context after nested context exits', async () => {
|
||||
const capturedIds: (string | undefined)[] = []
|
||||
|
||||
await run(async () => {
|
||||
capturedIds.push(getRequestId())
|
||||
|
||||
await run(async () => {
|
||||
capturedIds.push(getRequestId())
|
||||
})
|
||||
|
||||
capturedIds.push(getRequestId())
|
||||
})
|
||||
|
||||
expect(capturedIds).toHaveLength(3)
|
||||
expect(capturedIds[0]).toBe(capturedIds[2]) // Before and after should match
|
||||
expect(capturedIds[0]).not.toBe(capturedIds[1]) // Inner should be different
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Request Scenarios', () => {
|
||||
it('should return undefined requestId outside context', async () => {
|
||||
const requestId = getRequestId()
|
||||
expect(requestId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined context outside context', async () => {
|
||||
const context = getContext()
|
||||
expect(context).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should work normally without RequestContext wrapper', async () => {
|
||||
// This simulates existing code that doesn't use request context
|
||||
expect(getRequestId()).toBeUndefined()
|
||||
|
||||
const result = await Promise.resolve('test')
|
||||
expect(result).toBe('test')
|
||||
|
||||
// Still undefined after regular async operation
|
||||
expect(getRequestId()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withContext()', () => {
|
||||
it('should override operation in nested context', async () => {
|
||||
const operations: (string | undefined)[] = []
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
operations.push(getContext()?.operation)
|
||||
|
||||
await withContext(
|
||||
async () => {
|
||||
operations.push(getContext()?.operation)
|
||||
},
|
||||
{ operation: 'inner-operation' }
|
||||
)
|
||||
|
||||
operations.push(getContext()?.operation)
|
||||
},
|
||||
{ operation: 'outer-operation' }
|
||||
)
|
||||
|
||||
expect(operations).toHaveLength(3)
|
||||
expect(operations[0]).toBe('outer-operation')
|
||||
expect(operations[1]).toBe('inner-operation')
|
||||
expect(operations[2]).toBe('outer-operation')
|
||||
})
|
||||
|
||||
it('should override userId in nested context', async () => {
|
||||
const userIds: (string | undefined)[] = []
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
userIds.push(getContext()?.userId)
|
||||
|
||||
await withContext(
|
||||
async () => {
|
||||
userIds.push(getContext()?.userId)
|
||||
},
|
||||
{ userId: 'inner-user' }
|
||||
)
|
||||
|
||||
userIds.push(getContext()?.userId)
|
||||
},
|
||||
{ userId: 'outer-user' }
|
||||
)
|
||||
|
||||
expect(userIds).toHaveLength(3)
|
||||
expect(userIds[0]).toBe('outer-user')
|
||||
expect(userIds[1]).toBe('inner-user')
|
||||
expect(userIds[2]).toBe('outer-user')
|
||||
})
|
||||
|
||||
it('should create new requestId if no outer context exists', async () => {
|
||||
let requestIdInWith: string | undefined
|
||||
|
||||
await withContext(
|
||||
async () => {
|
||||
requestIdInWith = getRequestId()
|
||||
},
|
||||
{ operation: 'standalone' }
|
||||
)
|
||||
|
||||
expect(requestIdInWith).toBeDefined()
|
||||
expect(getContext()).toBeUndefined() // Back to undefined after exiting
|
||||
})
|
||||
|
||||
it('should preserve requestId when overriding other fields', async () => {
|
||||
const requestIds: (string | undefined)[] = []
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
requestIds.push(getRequestId())
|
||||
|
||||
await withContext(
|
||||
async () => {
|
||||
requestIds.push(getRequestId())
|
||||
},
|
||||
{ operation: 'new-operation' }
|
||||
)
|
||||
|
||||
requestIds.push(getRequestId())
|
||||
},
|
||||
{ userId: 'test-user' }
|
||||
)
|
||||
|
||||
// requestId should remain the same across all scopes
|
||||
expect(requestIds).toHaveLength(3)
|
||||
expect(new Set(requestIds).size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle errors within context gracefully', async () => {
|
||||
let caughtRequestId: string | undefined
|
||||
|
||||
try {
|
||||
await run(async () => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
} catch (error) {
|
||||
// Error caught, context should be cleaned up
|
||||
caughtRequestId = getRequestId()
|
||||
}
|
||||
|
||||
expect(caughtRequestId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle context in try-catch-finally', async () => {
|
||||
const tryId: string | undefined = undefined
|
||||
const finallyId: string | undefined = undefined
|
||||
|
||||
await run(async () => {
|
||||
try {
|
||||
const id = getRequestId()
|
||||
expect(id).toBeDefined()
|
||||
throw new Error('Test')
|
||||
} catch {
|
||||
const id = getRequestId()
|
||||
expect(id).toBeDefined()
|
||||
} finally {
|
||||
const id = getRequestId()
|
||||
expect(id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty string userId and operation', async () => {
|
||||
let context: LoggerContext | undefined
|
||||
|
||||
await run(
|
||||
async () => {
|
||||
context = getContext()
|
||||
},
|
||||
{ userId: '', operation: '' }
|
||||
)
|
||||
|
||||
expect(context?.userId).toBe('')
|
||||
expect(context?.operation).toBe('')
|
||||
expect(context?.requestId).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
561
tests/unit/services/logger/error-utils.test.ts
Normal file
561
tests/unit/services/logger/error-utils.test.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user