All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m51s
295 lines
9.0 KiB
TypeScript
295 lines
9.0 KiB
TypeScript
// src/services/logger.server.test.ts
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// Unmock the module we are testing to override the global mock from setupFiles.
|
|
vi.unmock('./logger.server');
|
|
|
|
// Mock fs to prevent actual file system operations
|
|
vi.mock('fs', () => ({
|
|
default: {
|
|
existsSync: vi.fn(() => true),
|
|
mkdirSync: vi.fn(),
|
|
},
|
|
existsSync: vi.fn(() => true),
|
|
mkdirSync: vi.fn(),
|
|
}));
|
|
|
|
// Create mock objects for pino's multistream functionality
|
|
const mockDestinationStream = { write: vi.fn() };
|
|
const mockMultistream = { write: vi.fn() };
|
|
|
|
// Mock pino before importing the logger
|
|
// The new logger uses pino.destination() and pino.multistream() for production/test
|
|
const mockLoggerInstance = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
level: 'info',
|
|
child: vi.fn(() => mockLoggerInstance),
|
|
};
|
|
|
|
// Create a properly typed mock that includes pino's static methods
|
|
const mockDestination = vi.fn(() => mockDestinationStream);
|
|
const mockMultistreamFn = vi.fn(() => mockMultistream);
|
|
|
|
const pinoMock = Object.assign(
|
|
vi.fn(() => mockLoggerInstance),
|
|
{
|
|
destination: mockDestination,
|
|
multistream: mockMultistreamFn,
|
|
},
|
|
);
|
|
|
|
vi.mock('pino', () => ({ default: pinoMock }));
|
|
|
|
describe('Server Logger', () => {
|
|
beforeEach(() => {
|
|
// Reset modules to ensure process.env changes are applied to new module instances
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('should initialize pino with multistream for production (stdout + file)', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
await import('./logger.server');
|
|
|
|
// Production uses pino.destination for file output
|
|
expect(mockDestination).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dest: expect.stringContaining('app.log'),
|
|
sync: false,
|
|
mkdir: true,
|
|
}),
|
|
);
|
|
|
|
// Production uses pino.multistream to combine stdout and file streams
|
|
expect(mockMultistreamFn).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ stream: process.stdout }),
|
|
expect.objectContaining({ stream: mockDestinationStream }),
|
|
]),
|
|
);
|
|
|
|
// pino is called with level 'info' for production
|
|
expect(pinoMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ level: 'info' }),
|
|
mockMultistream,
|
|
);
|
|
});
|
|
|
|
it('should initialize pino with pretty-print transport for development', async () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
await import('./logger.server');
|
|
|
|
// Development does NOT use destination or multistream
|
|
expect(mockDestination).not.toHaveBeenCalled();
|
|
expect(mockMultistreamFn).not.toHaveBeenCalled();
|
|
|
|
// Development uses pino-pretty transport
|
|
expect(pinoMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
level: 'debug',
|
|
transport: expect.objectContaining({
|
|
target: 'pino-pretty',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should initialize pino with multistream for test (stdout + file)', async () => {
|
|
// This is the default for vitest, but we stub it for clarity.
|
|
vi.stubEnv('NODE_ENV', 'test');
|
|
await import('./logger.server');
|
|
|
|
// Test env also uses file logging like production
|
|
expect(mockDestination).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dest: expect.stringContaining('app.log'),
|
|
sync: false,
|
|
mkdir: true,
|
|
}),
|
|
);
|
|
|
|
expect(mockMultistreamFn).toHaveBeenCalled();
|
|
|
|
// Test uses debug level
|
|
expect(pinoMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ level: 'debug' }),
|
|
mockMultistream,
|
|
);
|
|
});
|
|
|
|
it('should use LOG_DIR environment variable when set', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
vi.stubEnv('LOG_DIR', '/custom/log/dir');
|
|
await import('./logger.server');
|
|
|
|
// Should use the custom LOG_DIR in the file path
|
|
expect(mockDestination).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dest: '/custom/log/dir/app.log',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should fall back to stdout only when log directory creation fails', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
|
|
// Mock fs.existsSync to return false (dir doesn't exist)
|
|
// and mkdirSync to throw an error
|
|
const fs = await import('fs');
|
|
vi.mocked(fs.default.existsSync).mockReturnValue(false);
|
|
vi.mocked(fs.default.mkdirSync).mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
// Suppress console.error during this test
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await import('./logger.server');
|
|
|
|
// Should have tried to create directory
|
|
expect(fs.default.mkdirSync).toHaveBeenCalled();
|
|
|
|
// Should log error to console
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to create log directory'),
|
|
expect.any(Error),
|
|
);
|
|
|
|
// Should fall back to stdout-only logger (no multistream)
|
|
// When logDir is null, pino is called without multistream
|
|
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
describe('createScopedLogger', () => {
|
|
it('should create a child logger with module name', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
const { createScopedLogger } = await import('./logger.server');
|
|
|
|
const scopedLogger = createScopedLogger('test-module');
|
|
|
|
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
|
expect.objectContaining({ module: 'test-module' }),
|
|
);
|
|
expect(scopedLogger).toBeDefined();
|
|
});
|
|
|
|
it('should enable debug level when DEBUG_MODULES includes module name', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
vi.stubEnv('DEBUG_MODULES', 'test-module,other-module');
|
|
const { createScopedLogger } = await import('./logger.server');
|
|
|
|
createScopedLogger('test-module');
|
|
|
|
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
module: 'test-module',
|
|
level: 'debug',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should enable debug level when DEBUG_MODULES includes wildcard', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
vi.stubEnv('DEBUG_MODULES', '*');
|
|
const { createScopedLogger } = await import('./logger.server');
|
|
|
|
createScopedLogger('any-module');
|
|
|
|
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
module: 'any-module',
|
|
level: 'debug',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use default level when module not in DEBUG_MODULES', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
vi.stubEnv('DEBUG_MODULES', 'other-module');
|
|
const { createScopedLogger } = await import('./logger.server');
|
|
|
|
createScopedLogger('test-module');
|
|
|
|
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
module: 'test-module',
|
|
level: 'info', // Uses logger.level which is 'info'
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle empty DEBUG_MODULES', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
vi.stubEnv('DEBUG_MODULES', '');
|
|
const { createScopedLogger } = await import('./logger.server');
|
|
|
|
createScopedLogger('test-module');
|
|
|
|
expect(mockLoggerInstance.child).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
module: 'test-module',
|
|
level: 'info',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('redaction configuration', () => {
|
|
it('should configure redaction for sensitive fields', async () => {
|
|
// Reset fs mock to ensure directory creation succeeds
|
|
const fs = await import('fs');
|
|
vi.mocked(fs.default.existsSync).mockReturnValue(true);
|
|
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
await import('./logger.server');
|
|
|
|
// Verify redact configuration is passed to pino
|
|
// When log directory exists, pino is called with config and multistream
|
|
expect(pinoMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
redact: expect.objectContaining({
|
|
paths: expect.arrayContaining([
|
|
'req.headers.authorization',
|
|
'req.headers.cookie',
|
|
'*.body.password',
|
|
'*.body.newPassword',
|
|
'*.body.currentPassword',
|
|
'*.body.confirmPassword',
|
|
'*.body.refreshToken',
|
|
'*.body.token',
|
|
]),
|
|
censor: '[REDACTED]',
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('environment detection', () => {
|
|
it('should treat undefined NODE_ENV as development', async () => {
|
|
vi.stubEnv('NODE_ENV', '');
|
|
await import('./logger.server');
|
|
|
|
// Development uses pino-pretty transport
|
|
expect(pinoMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
transport: expect.objectContaining({
|
|
target: 'pino-pretty',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|