Files
flyer-crawler.projectium.com/src/services/sentry.server.test.ts
Torben Sorensen 2075ed199b Complete ADR-008 Phase 1: API Versioning Strategy
Implement URI-based API versioning with /api/v1 prefix across all routes.
This establishes a foundation for future API evolution and breaking changes.

Changes:
- server.ts: All routes mounted under /api/v1/ (15 route handlers)
- apiClient.ts: Base URL updated to /api/v1
- swagger.ts: OpenAPI server URL changed to /api/v1
- Redirect middleware: Added backwards compatibility for /api/* → /api/v1/*
- Tests: Updated 72 test files with versioned path assertions
- ADR documentation: Marked Phase 1 as complete (Accepted status)

Test fixes:
- apiClient.test.ts: 27 tests updated for /api/v1 paths
- user.routes.ts: 36 log messages updated to reflect versioned paths
- swagger.test.ts: 1 test updated for new server URL
- All integration/E2E tests updated for versioned endpoints

All Phase 1 acceptance criteria met:
✓ Routes use /api/v1/ prefix
✓ Frontend requests /api/v1/
✓ OpenAPI docs reflect /api/v1/
✓ Backwards compatibility via redirect middleware
✓ Tests pass with versioned paths

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 21:23:25 -08:00

339 lines
10 KiB
TypeScript

// src/services/sentry.server.test.ts
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,
}));
// Mock config/env module - by default isSentryConfigured is false and isTest is true
vi.mock('../config/env', () => ({
config: {
sentry: {
dsn: '',
environment: 'test',
debug: false,
},
server: {
nodeEnv: 'test',
},
},
isSentryConfigured: false,
isProduction: false,
isTest: true,
}));
describe('sentry.server', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('with Sentry disabled (default test environment)', () => {
beforeEach(() => {
vi.resetModules();
});
it('should not initialize Sentry when not configured', async () => {
const { initSentry } = await import('./sentry.server');
initSentry();
// Sentry.init should NOT be called when DSN is not configured
expect(mockSentry.init).not.toHaveBeenCalled();
});
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 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 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 add breadcrumb when not configured', async () => {
const { addBreadcrumb } = await import('./sentry.server');
addBreadcrumb({ message: 'test breadcrumb', category: 'test' });
expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled();
});
});
describe('Sentry re-export', () => {
it('should re-export Sentry object', async () => {
const { Sentry } = await import('./sentry.server');
expect(Sentry).toBeDefined();
expect(Sentry.init).toBeDefined();
expect(Sentry.captureException).toBeDefined();
});
});
describe('getSentryMiddleware', () => {
beforeEach(() => {
vi.resetModules();
});
it('should return no-op middleware when Sentry is not configured', async () => {
const { getSentryMiddleware } = await import('./sentry.server');
const middleware = getSentryMiddleware();
expect(middleware.requestHandler).toBeDefined();
expect(middleware.errorHandler).toBeDefined();
});
it('should have requestHandler that calls next()', async () => {
const { getSentryMiddleware } = await import('./sentry.server');
const middleware = getSentryMiddleware();
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()', 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);
});
});
describe('initSentry beforeSend logic', () => {
// Test the beforeSend logic in isolation
it('should return event from beforeSend', () => {
// 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 }) => {
// In development, log errors - but don't do extra processing
if (!isProduction && hint.originalException) {
// Would log here in real implementation
}
return event;
};
const result = beforeSend(mockEvent, {});
expect(result).toBe(mockEvent);
});
it('should return event in development with original exception', () => {
// Simulate the beforeSend logic when isProduction is false
const isProduction = false;
const mockEvent = { event_id: '123' };
const mockException = new Error('test');
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, { originalException: mockException });
expect(result).toBe(mockEvent);
});
});
describe('error handler status code logic', () => {
// Test the error handler's status code filtering logic in isolation
it('should identify 5xx errors for Sentry capture', () => {
// Test the logic that determines if an error should be captured
const shouldCapture = (statusCode: number) => statusCode >= 500;
expect(shouldCapture(500)).toBe(true);
expect(shouldCapture(502)).toBe(true);
expect(shouldCapture(503)).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);
});
it('should extract statusCode from error object', () => {
// Test the status code extraction logic
const getStatusCode = (err: Error & { statusCode?: number; status?: number }) =>
err.statusCode || err.status || 500;
const errorWithStatusCode = Object.assign(new Error('test'), { statusCode: 503 });
const errorWithStatus = Object.assign(new Error('test'), { status: 502 });
const plainError = new Error('test');
expect(getStatusCode(errorWithStatusCode)).toBe(503);
expect(getStatusCode(errorWithStatus)).toBe(502);
expect(getStatusCode(plainError)).toBe(500);
});
});
describe('isSentryConfigured and isTest guard logic', () => {
// Test the guard condition logic used throughout the module
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', () => {
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);
});
});
describe('captureException with context', () => {
// Test the context-setting logic
it('should set context when provided', () => {
const context = { userId: '123', action: 'test' };
const shouldSetContext = !!context;
expect(shouldSetContext).toBe(true);
});
it('should not set context when not provided', () => {
const context = undefined;
const shouldSetContext = !!context;
expect(shouldSetContext).toBe(false);
});
});
describe('captureMessage default level', () => {
it('should default to info level', () => {
// Test the default parameter behavior
const defaultLevel = 'info';
expect(defaultLevel).toBe('info');
});
it('should accept other severity levels', () => {
const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'];
validLevels.forEach((level) => {
expect(['fatal', 'error', 'warning', 'log', 'info', 'debug']).toContain(level);
});
});
});
describe('setUser', () => {
it('should accept user object with id only', () => {
const user = { id: '123' };
expect(user.id).toBe('123');
expect(user).not.toHaveProperty('email');
});
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();
});
});
describe('addBreadcrumb', () => {
it('should accept breadcrumb with message', () => {
const breadcrumb = { message: 'User clicked button' };
expect(breadcrumb.message).toBe('User clicked button');
});
it('should accept breadcrumb with category', () => {
const breadcrumb = { message: 'Navigation', category: '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', () => {
const breadcrumb = {
message: 'API call',
category: 'http',
data: { url: '/api/v1/test', method: 'GET' },
};
expect(breadcrumb.data).toEqual({ url: '/api/v1/test', method: 'GET' });
});
});
});