433 lines
12 KiB
TypeScript
433 lines
12 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|
|
})
|