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>
339 lines
10 KiB
TypeScript
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' });
|
|
});
|
|
});
|
|
});
|