Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m28s
1186 lines
39 KiB
TypeScript
1186 lines
39 KiB
TypeScript
// src/services/sentry.server.test.ts
|
|
/**
|
|
* Comprehensive unit tests for the Sentry server-side initialization and configuration.
|
|
*
|
|
* This test file covers:
|
|
* - Sentry.init() is called with correct configuration
|
|
* - Environment-based configuration (development vs production)
|
|
* - DSN configuration
|
|
* - Integration setup (server-side integrations)
|
|
* - Error handling when Sentry is not configured
|
|
* - Initialization behavior based on environment
|
|
* - Middleware behavior (requestHandler and errorHandler)
|
|
* - captureException, captureMessage, setUser, addBreadcrumb functions
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
|
|
// Use vi.hoisted to define mocks that need to be available before vi.mock runs
|
|
const { mockSentry, mockLogger } = vi.hoisted(() => ({
|
|
mockSentry: {
|
|
init: vi.fn(),
|
|
captureException: vi.fn(() => 'mock-event-id'),
|
|
captureMessage: vi.fn(() => 'mock-message-id'),
|
|
setContext: vi.fn(),
|
|
setUser: vi.fn(),
|
|
addBreadcrumb: vi.fn(),
|
|
},
|
|
mockLogger: {
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@sentry/node', () => mockSentry);
|
|
|
|
vi.mock('./logger.server', () => ({
|
|
logger: mockLogger,
|
|
}));
|
|
|
|
describe('sentry.server', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 1: Tests with Sentry DISABLED (default test environment)
|
|
// ============================================================================
|
|
describe('with Sentry disabled (isSentryConfigured=false)', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
// Mock config/env module - Sentry is NOT configured
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: '',
|
|
environment: 'test',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'test',
|
|
},
|
|
},
|
|
isSentryConfigured: false,
|
|
isProduction: false,
|
|
isTest: true,
|
|
}));
|
|
});
|
|
|
|
it('should not initialize Sentry when DSN is not configured', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).not.toHaveBeenCalled();
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
'[Sentry] Error tracking disabled (SENTRY_DSN not configured)',
|
|
);
|
|
});
|
|
|
|
it('should return null from captureException when not configured', async () => {
|
|
const { captureException } = await import('./sentry.server');
|
|
|
|
const result = captureException(new Error('test error'));
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return null from captureException with context when not configured', async () => {
|
|
const { captureException } = await import('./sentry.server');
|
|
|
|
const result = captureException(new Error('test error'), { userId: '123', action: 'test' });
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockSentry.setContext).not.toHaveBeenCalled();
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return null from captureMessage when not configured', async () => {
|
|
const { captureMessage } = await import('./sentry.server');
|
|
|
|
const result = captureMessage('test message');
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return null from captureMessage with custom level when not configured', async () => {
|
|
const { captureMessage } = await import('./sentry.server');
|
|
|
|
const result = captureMessage('warning message', 'warning');
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockSentry.captureMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not set user when not configured', async () => {
|
|
const { setUser } = await import('./sentry.server');
|
|
|
|
setUser({ id: '123', email: 'test@example.com' });
|
|
|
|
expect(mockSentry.setUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not clear user when not configured', async () => {
|
|
const { setUser } = await import('./sentry.server');
|
|
|
|
setUser(null);
|
|
|
|
expect(mockSentry.setUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not add breadcrumb when not configured', async () => {
|
|
const { addBreadcrumb } = await import('./sentry.server');
|
|
|
|
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
|
|
|
|
expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return no-op middleware when not configured', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
|
|
const middleware = getSentryMiddleware();
|
|
|
|
expect(middleware.requestHandler).toBeDefined();
|
|
expect(middleware.errorHandler).toBeDefined();
|
|
|
|
// Test that requestHandler just calls next()
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn() as unknown as NextFunction;
|
|
|
|
middleware.requestHandler(req, res, next);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('should have errorHandler that passes error to next() when not configured', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = new Error('test error');
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn() as unknown as NextFunction;
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 2: Tests with Sentry configured but in TEST environment
|
|
// ============================================================================
|
|
describe('with Sentry configured but in test environment (isTest=true)', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: 'test',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'test',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: false,
|
|
isTest: true,
|
|
}));
|
|
});
|
|
|
|
it('should skip initialization in test environment even when configured', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).not.toHaveBeenCalled();
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
'[Sentry] Skipping initialization in test environment',
|
|
);
|
|
});
|
|
|
|
it('should return null from captureException in test environment', async () => {
|
|
const { captureException } = await import('./sentry.server');
|
|
|
|
const result = captureException(new Error('test error'));
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return null from captureMessage in test environment', async () => {
|
|
const { captureMessage } = await import('./sentry.server');
|
|
|
|
const result = captureMessage('test message');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should not set user in test environment', async () => {
|
|
const { setUser } = await import('./sentry.server');
|
|
|
|
setUser({ id: '123' });
|
|
|
|
expect(mockSentry.setUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not add breadcrumb in test environment', async () => {
|
|
const { addBreadcrumb } = await import('./sentry.server');
|
|
|
|
addBreadcrumb({ message: 'test' });
|
|
|
|
expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return no-op middleware in test environment', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
|
|
const middleware = getSentryMiddleware();
|
|
const next = vi.fn();
|
|
|
|
middleware.requestHandler({} as Request, {} as Response, next);
|
|
expect(next).toHaveBeenCalledWith();
|
|
|
|
const error = new Error('test');
|
|
middleware.errorHandler(error, {} as Request, {} as Response, next);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 3: Tests with Sentry ENABLED in development environment
|
|
// ============================================================================
|
|
describe('with Sentry enabled in development environment', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@bugsink.example.com/123',
|
|
environment: 'development',
|
|
debug: true,
|
|
},
|
|
server: {
|
|
nodeEnv: 'development',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: false,
|
|
isTest: false,
|
|
}));
|
|
});
|
|
|
|
it('should initialize Sentry with correct configuration in development', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledTimes(1);
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dsn: 'https://key@bugsink.example.com/123',
|
|
environment: 'development',
|
|
debug: true,
|
|
tracesSampleRate: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should log initialization message with environment', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
{ environment: 'development' },
|
|
'[Sentry] Error tracking initialized',
|
|
);
|
|
});
|
|
|
|
it('should capture exception and return event ID when configured', async () => {
|
|
const { captureException } = await import('./sentry.server');
|
|
const testError = new Error('Test error for Sentry');
|
|
|
|
const result = captureException(testError);
|
|
|
|
expect(result).toBe('mock-event-id');
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(testError);
|
|
});
|
|
|
|
it('should set context before capturing exception when context is provided', async () => {
|
|
const { captureException } = await import('./sentry.server');
|
|
const testError = new Error('Test error');
|
|
const context = { userId: '123', action: 'test-action' };
|
|
|
|
captureException(testError, context);
|
|
|
|
expect(mockSentry.setContext).toHaveBeenCalledWith('additional', context);
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(testError);
|
|
});
|
|
|
|
it('should not set context when context is not provided', async () => {
|
|
const { captureException } = await import('./sentry.server');
|
|
const testError = new Error('Test error');
|
|
|
|
captureException(testError);
|
|
|
|
expect(mockSentry.setContext).not.toHaveBeenCalled();
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(testError);
|
|
});
|
|
|
|
it('should capture message with default info level', async () => {
|
|
const { captureMessage } = await import('./sentry.server');
|
|
|
|
const result = captureMessage('Test message');
|
|
|
|
expect(result).toBe('mock-message-id');
|
|
expect(mockSentry.captureMessage).toHaveBeenCalledWith('Test message', 'info');
|
|
});
|
|
|
|
it('should capture message with custom severity level', async () => {
|
|
const { captureMessage } = await import('./sentry.server');
|
|
|
|
captureMessage('Error occurred', 'error');
|
|
|
|
expect(mockSentry.captureMessage).toHaveBeenCalledWith('Error occurred', 'error');
|
|
});
|
|
|
|
it('should capture message with warning level', async () => {
|
|
const { captureMessage } = await import('./sentry.server');
|
|
|
|
captureMessage('Warning message', 'warning');
|
|
|
|
expect(mockSentry.captureMessage).toHaveBeenCalledWith('Warning message', 'warning');
|
|
});
|
|
|
|
it('should set user context when provided', async () => {
|
|
const { setUser } = await import('./sentry.server');
|
|
|
|
setUser({ id: 'user-123', email: 'user@example.com', username: 'testuser' });
|
|
|
|
expect(mockSentry.setUser).toHaveBeenCalledWith({
|
|
id: 'user-123',
|
|
email: 'user@example.com',
|
|
username: 'testuser',
|
|
});
|
|
});
|
|
|
|
it('should set user with id only', async () => {
|
|
const { setUser } = await import('./sentry.server');
|
|
|
|
setUser({ id: 'user-456' });
|
|
|
|
expect(mockSentry.setUser).toHaveBeenCalledWith({ id: 'user-456' });
|
|
});
|
|
|
|
it('should clear user by passing null', async () => {
|
|
const { setUser } = await import('./sentry.server');
|
|
|
|
setUser(null);
|
|
|
|
expect(mockSentry.setUser).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it('should add breadcrumb with message', async () => {
|
|
const { addBreadcrumb } = await import('./sentry.server');
|
|
|
|
addBreadcrumb({ message: 'User clicked button' });
|
|
|
|
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith({ message: 'User clicked button' });
|
|
});
|
|
|
|
it('should add breadcrumb with category and level', async () => {
|
|
const { addBreadcrumb } = await import('./sentry.server');
|
|
|
|
addBreadcrumb({ message: 'Navigation', category: 'navigation', level: 'info' });
|
|
|
|
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith({
|
|
message: 'Navigation',
|
|
category: 'navigation',
|
|
level: 'info',
|
|
});
|
|
});
|
|
|
|
it('should add breadcrumb with data', async () => {
|
|
const { addBreadcrumb } = await import('./sentry.server');
|
|
|
|
addBreadcrumb({
|
|
message: 'API call',
|
|
category: 'http',
|
|
data: { url: '/api/v1/test', method: 'GET', status_code: 200 },
|
|
});
|
|
|
|
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith({
|
|
message: 'API call',
|
|
category: 'http',
|
|
data: { url: '/api/v1/test', method: 'GET', status_code: 200 },
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 4: Tests with Sentry ENABLED in production environment
|
|
// ============================================================================
|
|
describe('with Sentry enabled in production environment', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://prodkey@bugsink.projectium.com/1',
|
|
environment: 'production',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'production',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: true,
|
|
isTest: false,
|
|
}));
|
|
});
|
|
|
|
it('should initialize Sentry with production configuration', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledTimes(1);
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dsn: 'https://prodkey@bugsink.projectium.com/1',
|
|
environment: 'production',
|
|
debug: false,
|
|
tracesSampleRate: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should log initialization with production environment', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
{ environment: 'production' },
|
|
'[Sentry] Error tracking initialized',
|
|
);
|
|
});
|
|
|
|
it('should include beforeSend callback in init config', async () => {
|
|
const { initSentry } = await import('./sentry.server');
|
|
|
|
initSentry();
|
|
|
|
const initCall = mockSentry.init.mock.calls[0][0];
|
|
expect(initCall.beforeSend).toBeDefined();
|
|
expect(typeof initCall.beforeSend).toBe('function');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 5: Tests for getSentryMiddleware when Sentry is enabled
|
|
// ============================================================================
|
|
describe('getSentryMiddleware when Sentry is enabled', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: 'development',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'development',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: false,
|
|
isTest: false,
|
|
}));
|
|
});
|
|
|
|
it('should return functional middleware when Sentry is configured', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
|
|
const middleware = getSentryMiddleware();
|
|
|
|
expect(middleware.requestHandler).toBeDefined();
|
|
expect(middleware.errorHandler).toBeDefined();
|
|
});
|
|
|
|
it('requestHandler should call next() in SDK v8+ compatibility mode', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.requestHandler(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('errorHandler should capture 5xx errors to Sentry', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Internal Server Error'), { statusCode: 500 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should capture 502 Bad Gateway errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Bad Gateway'), { statusCode: 502 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should capture 503 Service Unavailable errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Service Unavailable'), { statusCode: 503 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should NOT capture 4xx errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Not Found'), { statusCode: 404 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should NOT capture 400 Bad Request errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Bad Request'), { statusCode: 400 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should NOT capture 401 Unauthorized errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Unauthorized'), { statusCode: 401 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should NOT capture 403 Forbidden errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should NOT capture 422 Unprocessable Entity errors', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Unprocessable Entity'), { statusCode: 422 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).not.toHaveBeenCalled();
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should use status property if statusCode is not present', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = Object.assign(new Error('Service Error'), { status: 503 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should default to 500 if no status code is present', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error = new Error('Unknown error');
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error, req, res, next);
|
|
|
|
// Default status 500 >= 500, so it should capture
|
|
expect(mockSentry.captureException).toHaveBeenCalledWith(error);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('errorHandler should always pass error to next middleware', async () => {
|
|
const { getSentryMiddleware } = await import('./sentry.server');
|
|
const middleware = getSentryMiddleware();
|
|
|
|
const error404 = Object.assign(new Error('Not Found'), { statusCode: 404 });
|
|
const error500 = Object.assign(new Error('Server Error'), { statusCode: 500 });
|
|
const req = {} as Request;
|
|
const res = {} as Response;
|
|
const next = vi.fn();
|
|
|
|
middleware.errorHandler(error404, req, res, next);
|
|
expect(next).toHaveBeenCalledWith(error404);
|
|
|
|
next.mockClear();
|
|
|
|
middleware.errorHandler(error500, req, res, next);
|
|
expect(next).toHaveBeenCalledWith(error500);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 6: Tests for beforeSend callback behavior
|
|
// ============================================================================
|
|
describe('beforeSend callback behavior', () => {
|
|
it('should return event from beforeSend in production (no logging)', () => {
|
|
// Simulate the beforeSend logic when isProduction is true
|
|
const isProduction = true;
|
|
const mockEvent = { event_id: '123' };
|
|
|
|
const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => {
|
|
if (!isProduction && hint.originalException) {
|
|
// Would log here in real implementation
|
|
}
|
|
return event;
|
|
};
|
|
|
|
const result = beforeSend(mockEvent, {});
|
|
|
|
expect(result).toBe(mockEvent);
|
|
});
|
|
|
|
it('should return event and allow logging in development with original exception', () => {
|
|
// Simulate the beforeSend logic when isProduction is false
|
|
const isProduction = false;
|
|
const mockEvent = { event_id: '456' };
|
|
const mockException = new Error('test error');
|
|
|
|
const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => {
|
|
if (!isProduction && hint.originalException) {
|
|
// In real implementation, this would log
|
|
}
|
|
return event;
|
|
};
|
|
|
|
const result = beforeSend(mockEvent, { originalException: mockException });
|
|
|
|
expect(result).toBe(mockEvent);
|
|
});
|
|
|
|
it('should return event in development without original exception', () => {
|
|
const isProduction = false;
|
|
const mockEvent = { event_id: '789' };
|
|
|
|
const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => {
|
|
if (!isProduction && hint.originalException) {
|
|
// Would not log since no originalException
|
|
}
|
|
return event;
|
|
};
|
|
|
|
const result = beforeSend(mockEvent, {});
|
|
|
|
expect(result).toBe(mockEvent);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 7: Tests for Sentry environment fallback
|
|
// ============================================================================
|
|
describe('environment configuration fallback', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('should use sentry.environment when provided', async () => {
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: 'staging',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'production',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: true,
|
|
isTest: false,
|
|
}));
|
|
|
|
const { initSentry } = await import('./sentry.server');
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
environment: 'staging',
|
|
}),
|
|
);
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
{ environment: 'staging' },
|
|
'[Sentry] Error tracking initialized',
|
|
);
|
|
});
|
|
|
|
it('should fall back to server.nodeEnv when sentry.environment is not provided', async () => {
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: undefined,
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'production',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: true,
|
|
isTest: false,
|
|
}));
|
|
|
|
const { initSentry } = await import('./sentry.server');
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
environment: 'production',
|
|
}),
|
|
);
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
{ environment: 'production' },
|
|
'[Sentry] Error tracking initialized',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 8: Tests for Sentry re-export
|
|
// ============================================================================
|
|
describe('Sentry re-export', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: '',
|
|
environment: 'test',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'test',
|
|
},
|
|
},
|
|
isSentryConfigured: false,
|
|
isProduction: false,
|
|
isTest: true,
|
|
}));
|
|
});
|
|
|
|
it('should re-export Sentry object for advanced usage', async () => {
|
|
const { Sentry } = await import('./sentry.server');
|
|
|
|
expect(Sentry).toBeDefined();
|
|
expect(Sentry.init).toBeDefined();
|
|
expect(Sentry.captureException).toBeDefined();
|
|
expect(Sentry.captureMessage).toBeDefined();
|
|
expect(Sentry.setUser).toBeDefined();
|
|
expect(Sentry.addBreadcrumb).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 9: Tests for status code extraction logic
|
|
// ============================================================================
|
|
describe('error handler status code extraction logic', () => {
|
|
it('should correctly identify 5xx errors for Sentry capture', () => {
|
|
const shouldCapture = (statusCode: number) => statusCode >= 500;
|
|
|
|
expect(shouldCapture(500)).toBe(true);
|
|
expect(shouldCapture(501)).toBe(true);
|
|
expect(shouldCapture(502)).toBe(true);
|
|
expect(shouldCapture(503)).toBe(true);
|
|
expect(shouldCapture(504)).toBe(true);
|
|
expect(shouldCapture(599)).toBe(true);
|
|
});
|
|
|
|
it('should not capture 4xx errors', () => {
|
|
const shouldCapture = (statusCode: number) => statusCode >= 500;
|
|
|
|
expect(shouldCapture(400)).toBe(false);
|
|
expect(shouldCapture(401)).toBe(false);
|
|
expect(shouldCapture(403)).toBe(false);
|
|
expect(shouldCapture(404)).toBe(false);
|
|
expect(shouldCapture(422)).toBe(false);
|
|
expect(shouldCapture(499)).toBe(false);
|
|
});
|
|
|
|
it('should not capture 3xx or lower status codes', () => {
|
|
const shouldCapture = (statusCode: number) => statusCode >= 500;
|
|
|
|
expect(shouldCapture(200)).toBe(false);
|
|
expect(shouldCapture(201)).toBe(false);
|
|
expect(shouldCapture(301)).toBe(false);
|
|
expect(shouldCapture(302)).toBe(false);
|
|
});
|
|
|
|
it('should extract statusCode from error object', () => {
|
|
const getStatusCode = (err: Error & { statusCode?: number; status?: number }) =>
|
|
err.statusCode || err.status || 500;
|
|
|
|
const errorWithStatusCode = Object.assign(new Error('test'), { statusCode: 503 });
|
|
expect(getStatusCode(errorWithStatusCode)).toBe(503);
|
|
});
|
|
|
|
it('should extract status from error object when statusCode is missing', () => {
|
|
const getStatusCode = (err: Error & { statusCode?: number; status?: number }) =>
|
|
err.statusCode || err.status || 500;
|
|
|
|
const errorWithStatus = Object.assign(new Error('test'), { status: 502 });
|
|
expect(getStatusCode(errorWithStatus)).toBe(502);
|
|
});
|
|
|
|
it('should default to 500 for plain Error objects', () => {
|
|
const getStatusCode = (err: Error & { statusCode?: number; status?: number }) =>
|
|
err.statusCode || err.status || 500;
|
|
|
|
const plainError = new Error('test');
|
|
expect(getStatusCode(plainError)).toBe(500);
|
|
});
|
|
|
|
it('should prefer statusCode over status when both are present', () => {
|
|
const getStatusCode = (err: Error & { statusCode?: number; status?: number }) =>
|
|
err.statusCode || err.status || 500;
|
|
|
|
const errorWithBoth = Object.assign(new Error('test'), { statusCode: 500, status: 404 });
|
|
expect(getStatusCode(errorWithBoth)).toBe(500);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 10: Tests for guard condition logic
|
|
// ============================================================================
|
|
describe('isSentryConfigured and isTest guard logic', () => {
|
|
it('should block execution when Sentry is not configured', () => {
|
|
const isSentryConfigured = false;
|
|
const isTest = false;
|
|
|
|
const shouldExecute = isSentryConfigured && !isTest;
|
|
expect(shouldExecute).toBe(false);
|
|
});
|
|
|
|
it('should block execution in test environment even when configured', () => {
|
|
const isSentryConfigured = true;
|
|
const isTest = true;
|
|
|
|
const shouldExecute = isSentryConfigured && !isTest;
|
|
expect(shouldExecute).toBe(false);
|
|
});
|
|
|
|
it('should allow execution when configured and not in test', () => {
|
|
const isSentryConfigured = true;
|
|
const isTest = false;
|
|
|
|
const shouldExecute = isSentryConfigured && !isTest;
|
|
expect(shouldExecute).toBe(true);
|
|
});
|
|
|
|
it('should block execution when both conditions are false', () => {
|
|
const isSentryConfigured = false;
|
|
const isTest = true;
|
|
|
|
const shouldExecute = isSentryConfigured && !isTest;
|
|
expect(shouldExecute).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 11: Tests for captureMessage severity levels
|
|
// ============================================================================
|
|
describe('captureMessage severity levels', () => {
|
|
it('should support fatal level', () => {
|
|
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
|
expect(validLevels).toContain('fatal');
|
|
});
|
|
|
|
it('should support error level', () => {
|
|
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
|
expect(validLevels).toContain('error');
|
|
});
|
|
|
|
it('should support warning level', () => {
|
|
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
|
expect(validLevels).toContain('warning');
|
|
});
|
|
|
|
it('should support log level', () => {
|
|
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
|
expect(validLevels).toContain('log');
|
|
});
|
|
|
|
it('should support info level', () => {
|
|
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
|
expect(validLevels).toContain('info');
|
|
});
|
|
|
|
it('should support debug level', () => {
|
|
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
|
|
expect(validLevels).toContain('debug');
|
|
});
|
|
|
|
it('should have info as default level', () => {
|
|
const defaultLevel = 'info';
|
|
expect(defaultLevel).toBe('info');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 12: Tests for setUser parameter variations
|
|
// ============================================================================
|
|
describe('setUser parameter variations', () => {
|
|
it('should accept user object with id only', () => {
|
|
const user = { id: '123' };
|
|
expect(user.id).toBe('123');
|
|
expect(user).not.toHaveProperty('email');
|
|
expect(user).not.toHaveProperty('username');
|
|
});
|
|
|
|
it('should accept user object with id and email', () => {
|
|
const user = { id: '123', email: 'test@example.com' };
|
|
expect(user.id).toBe('123');
|
|
expect(user.email).toBe('test@example.com');
|
|
expect(user).not.toHaveProperty('username');
|
|
});
|
|
|
|
it('should accept user object with all fields', () => {
|
|
const user = { id: '123', email: 'test@example.com', username: 'testuser' };
|
|
expect(user.id).toBe('123');
|
|
expect(user.email).toBe('test@example.com');
|
|
expect(user.username).toBe('testuser');
|
|
});
|
|
|
|
it('should accept null to clear user', () => {
|
|
const user = null;
|
|
expect(user).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 13: Tests for addBreadcrumb parameter variations
|
|
// ============================================================================
|
|
describe('addBreadcrumb parameter variations', () => {
|
|
it('should accept breadcrumb with message only', () => {
|
|
const breadcrumb = { message: 'User clicked button' };
|
|
expect(breadcrumb.message).toBe('User clicked button');
|
|
});
|
|
|
|
it('should accept breadcrumb with message and category', () => {
|
|
const breadcrumb = { message: 'Navigation', category: 'navigation' };
|
|
expect(breadcrumb.message).toBe('Navigation');
|
|
expect(breadcrumb.category).toBe('navigation');
|
|
});
|
|
|
|
it('should accept breadcrumb with level', () => {
|
|
const breadcrumb = { message: 'Error occurred', level: 'error' as const };
|
|
expect(breadcrumb.level).toBe('error');
|
|
});
|
|
|
|
it('should accept breadcrumb with data object', () => {
|
|
const breadcrumb = {
|
|
message: 'API call',
|
|
category: 'http',
|
|
data: { url: '/api/v1/test', method: 'GET', status_code: 200 },
|
|
};
|
|
expect(breadcrumb.data).toEqual({ url: '/api/v1/test', method: 'GET', status_code: 200 });
|
|
});
|
|
|
|
it('should accept breadcrumb with timestamp', () => {
|
|
const now = Date.now();
|
|
const breadcrumb = { message: 'Event', timestamp: now };
|
|
expect(breadcrumb.timestamp).toBe(now);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 14: Tests for debug mode configuration
|
|
// ============================================================================
|
|
describe('debug mode configuration', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('should pass debug: true to Sentry.init when configured', async () => {
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: 'development',
|
|
debug: true,
|
|
},
|
|
server: {
|
|
nodeEnv: 'development',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: false,
|
|
isTest: false,
|
|
}));
|
|
|
|
const { initSentry } = await import('./sentry.server');
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
debug: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass debug: false to Sentry.init when configured', async () => {
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: 'production',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'production',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: true,
|
|
isTest: false,
|
|
}));
|
|
|
|
const { initSentry } = await import('./sentry.server');
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
debug: false,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// SECTION 15: Tests for tracesSampleRate configuration
|
|
// ============================================================================
|
|
describe('tracesSampleRate configuration', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('should set tracesSampleRate to 0 (performance monitoring disabled)', async () => {
|
|
vi.doMock('../config/env', () => ({
|
|
config: {
|
|
sentry: {
|
|
dsn: 'https://key@sentry.example.com/123',
|
|
environment: 'production',
|
|
debug: false,
|
|
},
|
|
server: {
|
|
nodeEnv: 'production',
|
|
},
|
|
},
|
|
isSentryConfigured: true,
|
|
isProduction: true,
|
|
isTest: false,
|
|
}));
|
|
|
|
const { initSentry } = await import('./sentry.server');
|
|
initSentry();
|
|
|
|
expect(mockSentry.init).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tracesSampleRate: 0,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|