feat(logging-p0): complete Wave 2 - Auth/Extractor/Cleaner services transformed
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user