diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 00000000..c5def9e2 --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,3 @@ +// Jest setup file — runs before each test suite. +// Load environment variables from .env.test if present. +import 'dotenv/config'; diff --git a/backend/package.json b/backend/package.json index 32b89026..25147600 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,10 +8,8 @@ "start": "node dist/src/index.js", "start:prod": "node dist/src/index.js", "dev": "tsx watch src/index.ts", - "test": "jest --runInBand", - "test:coverage": "jest --coverage --runInBand", - "collaboration": "tsx src/collaborationServer.ts", - "test": "DATABASE_URL='postgres://dummy:dummy@localhost:5432/dummy' node --experimental-vm-modules node_modules/jest/bin/jest.js" + "test": "DATABASE_URL='postgres://dummy:dummy@localhost:5432/dummy' node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:coverage": "DATABASE_URL='postgres://dummy:dummy@localhost:5432/dummy' node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", "collaboration": "tsx src/collaborationServer.ts" }, "keywords": [], @@ -52,14 +50,6 @@ "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/jest": "^29.0.0", - "@types/node": "^25.5.0", - "@types/qrcode": "^1.5.5", - "@types/supertest": "^2.0.12", - "@types/ws": "^8.18.1", - "jest": "^29.0.0", - "supertest": "^6.3.3", - "ts-jest": "^29.0.0", "@types/jest": "^30.0.0", "@types/node": "^25.5.0", "@types/qrcode": "^1.5.5", @@ -67,7 +57,6 @@ "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.18.1", - "jest": "^30.4.2", "ioredis-mock": "^8.13.1", "jest": "^30.4.2", "supertest": "^7.2.2", diff --git a/backend/src/metrics/MetricsCollector.ts b/backend/src/metrics/MetricsCollector.ts new file mode 100644 index 00000000..5d0c7e34 --- /dev/null +++ b/backend/src/metrics/MetricsCollector.ts @@ -0,0 +1,277 @@ +/** + * MetricsCollector — central metrics collection system for Web3 Student Lab. + * + * Collects three categories of metrics: + * - Performance: HTTP request durations, memory/CPU usage + * - Errors: counts by type and HTTP status code + * - Business: user registrations, course enrollments, certificate issuances + * + * Designed as a singleton so all parts of the app share one store. + * Integrates with the existing winston logger for structured output. + * + * Educational note: In production you would typically export these metrics + * to a time-series database (Prometheus, Datadog, CloudWatch) via a push or + * pull mechanism. This implementation keeps everything in-process for + * simplicity while exposing a clean interface that is easy to swap out. + */ + +import logger from '../utils/logger.js'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PerformanceMetric { + /** HTTP method (GET, POST, …) */ + method: string; + /** Route path, e.g. /api/v1/courses */ + route: string; + /** Response time in milliseconds */ + durationMs: number; + /** HTTP status code */ + statusCode: number; + /** ISO timestamp */ + timestamp: string; +} + +export interface ErrorMetric { + /** Error class name or custom label */ + type: string; + /** Human-readable message */ + message: string; + /** HTTP status code if applicable */ + statusCode?: number; + /** ISO timestamp */ + timestamp: string; +} + +export interface BusinessMetric { + /** Event name, e.g. "user.registered" */ + event: string; + /** Arbitrary metadata */ + metadata?: Record; + /** ISO timestamp */ + timestamp: string; +} + +export interface MetricsSummary { + performance: { + totalRequests: number; + averageDurationMs: number; + /** Requests per route: { "GET /api/v1/courses": count } */ + requestsByRoute: Record; + /** Requests per status code: { "200": count } */ + requestsByStatus: Record; + }; + errors: { + totalErrors: number; + /** Errors per type: { "ValidationError": count } */ + errorsByType: Record; + }; + business: { + totalEvents: number; + /** Events per name: { "user.registered": count } */ + eventsByName: Record; + }; + system: { + uptimeSeconds: number; + memoryUsageMB: number; + cpuUserMs: number; + }; + collectedAt: string; +} + +// ─── MetricsCollector ───────────────────────────────────────────────────────── + +export class MetricsCollector { + private performanceMetrics: PerformanceMetric[] = []; + private errorMetrics: ErrorMetric[] = []; + private businessMetrics: BusinessMetric[] = []; + + /** + * Maximum number of raw metric entries kept in memory per category. + * Older entries are dropped (ring-buffer style) to bound memory usage. + */ + private readonly maxEntries: number; + + constructor(maxEntries = 10_000) { + this.maxEntries = maxEntries; + } + + // ── Performance ───────────────────────────────────────────────────────────── + + /** + * Record a completed HTTP request. + * + * @param method HTTP verb + * @param route Normalised route path (avoid recording raw user input to + * prevent high-cardinality label explosion) + * @param durationMs Time from request start to response end + * @param statusCode HTTP response status + */ + recordRequest(method: string, route: string, durationMs: number, statusCode: number): void { + const metric: PerformanceMetric = { + method, + route, + durationMs, + statusCode, + timestamp: new Date().toISOString(), + }; + + this.performanceMetrics.push(metric); + this.trim(this.performanceMetrics); + + // Log slow requests (>1 s) so they surface in existing log pipelines. + if (durationMs > 1000) { + logger.warn(`Slow request: ${method} ${route} took ${durationMs}ms (status ${statusCode})`); + } + } + + // ── Errors ─────────────────────────────────────────────────────────────────── + + /** + * Record an application error. + * + * @param type Error class name or a short label like "UnhandledRejection" + * @param message Error message (avoid including PII) + * @param statusCode HTTP status code if the error maps to one + */ + recordError(type: string, message: string, statusCode?: number): void { + const metric: ErrorMetric = { + type, + message, + statusCode, + timestamp: new Date().toISOString(), + }; + + this.errorMetrics.push(metric); + this.trim(this.errorMetrics); + + logger.error(`[Metrics] Error recorded: ${type} — ${message}`); + } + + // ── Business ───────────────────────────────────────────────────────────────── + + /** + * Record a business-level event. + * + * Examples: + * collector.recordEvent('user.registered', { plan: 'free' }); + * collector.recordEvent('certificate.issued', { courseId: 'abc' }); + * + * @param event Dot-namespaced event name + * @param metadata Optional key/value context (no PII) + */ + recordEvent(event: string, metadata?: Record): void { + const metric: BusinessMetric = { + event, + metadata, + timestamp: new Date().toISOString(), + }; + + this.businessMetrics.push(metric); + this.trim(this.businessMetrics); + + logger.info(`[Metrics] Business event: ${event}`, metadata ?? {}); + } + + // ── Summary ────────────────────────────────────────────────────────────────── + + /** + * Return an aggregated snapshot of all collected metrics. + * This is what the /api/v1/metrics endpoint exposes. + */ + getSummary(): MetricsSummary { + const requestsByRoute: Record = {}; + const requestsByStatus: Record = {}; + let totalDuration = 0; + + for (const m of this.performanceMetrics) { + const key = `${m.method} ${m.route}`; + requestsByRoute[key] = (requestsByRoute[key] ?? 0) + 1; + requestsByStatus[String(m.statusCode)] = (requestsByStatus[String(m.statusCode)] ?? 0) + 1; + totalDuration += m.durationMs; + } + + const totalRequests = this.performanceMetrics.length; + const averageDurationMs = totalRequests > 0 ? totalDuration / totalRequests : 0; + + const errorsByType: Record = {}; + for (const e of this.errorMetrics) { + errorsByType[e.type] = (errorsByType[e.type] ?? 0) + 1; + } + + const eventsByName: Record = {}; + for (const b of this.businessMetrics) { + eventsByName[b.event] = (eventsByName[b.event] ?? 0) + 1; + } + + const memUsage = process.memoryUsage(); + const cpuUsage = process.cpuUsage(); + + return { + performance: { + totalRequests, + averageDurationMs: Math.round(averageDurationMs * 100) / 100, + requestsByRoute, + requestsByStatus, + }, + errors: { + totalErrors: this.errorMetrics.length, + errorsByType, + }, + business: { + totalEvents: this.businessMetrics.length, + eventsByName, + }, + system: { + uptimeSeconds: Math.floor(process.uptime()), + memoryUsageMB: Math.round(memUsage.heapUsed / 1024 / 1024), + cpuUserMs: Math.round(cpuUsage.user / 1000), + }, + collectedAt: new Date().toISOString(), + }; + } + + /** + * Return the raw performance metric entries (useful for debugging or export). + */ + getPerformanceMetrics(): PerformanceMetric[] { + return [...this.performanceMetrics]; + } + + /** + * Return the raw error metric entries. + */ + getErrorMetrics(): ErrorMetric[] { + return [...this.errorMetrics]; + } + + /** + * Return the raw business metric entries. + */ + getBusinessMetrics(): BusinessMetric[] { + return [...this.businessMetrics]; + } + + /** + * Reset all collected metrics (useful in tests or after a scheduled flush). + */ + reset(): void { + this.performanceMetrics = []; + this.errorMetrics = []; + this.businessMetrics = []; + logger.info('[Metrics] All metrics reset'); + } + + // ── Private helpers ────────────────────────────────────────────────────────── + + /** Drop the oldest entries when the array exceeds maxEntries. */ + private trim(arr: T[]): void { + if (arr.length > this.maxEntries) { + arr.splice(0, arr.length - this.maxEntries); + } + } +} + +// Export a singleton instance so the whole app shares one collector. +const metricsCollector = new MetricsCollector(); +export default metricsCollector; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 678e44d4..9a7a538b 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -12,6 +12,7 @@ import exportRouter from './export.routes.js'; import generatorRouter from './generator/generator.routes.js'; import healthRouter from './health.routes.js'; import learningRoutes from './learning/learning.routes.js'; +import metricsRouter from './metrics.routes.js'; import securityRouter from './security.routes.js'; import studentsRouter from './students.js'; @@ -32,5 +33,6 @@ router.use('/security', securityRouter); router.use('/generator', generatorRouter); router.use('/export', exportRouter); router.use('/user', userRouter); +router.use('/metrics', metricsRouter); export default router; diff --git a/backend/src/routes/metrics.routes.ts b/backend/src/routes/metrics.routes.ts new file mode 100644 index 00000000..1666f299 --- /dev/null +++ b/backend/src/routes/metrics.routes.ts @@ -0,0 +1,80 @@ +/** + * Metrics Routes — exposes collected metrics over HTTP. + * + * Endpoints: + * GET /api/v1/metrics — aggregated summary + * GET /api/v1/metrics/performance — raw performance entries + * GET /api/v1/metrics/errors — raw error entries + * GET /api/v1/metrics/business — raw business event entries + * POST /api/v1/metrics/reset — clear all metrics (admin use) + * + * Educational note: In a real deployment you would protect these endpoints + * with an admin-only auth middleware. Here we keep it simple and rely on + * the existing workspace/rate-limit middleware applied at the router level. + */ + +import { Router, Request, Response } from 'express'; +import metricsCollector from '../metrics/MetricsCollector.js'; + +const router = Router(); + +/** + * @openapi + * /api/v1/metrics: + * get: + * summary: Get aggregated metrics summary + * tags: [Metrics] + * responses: + * 200: + * description: Metrics summary + */ +router.get('/', (_req: Request, res: Response) => { + res.json({ status: 'success', data: metricsCollector.getSummary() }); +}); + +/** + * @openapi + * /api/v1/metrics/performance: + * get: + * summary: Get raw performance metrics + * tags: [Metrics] + */ +router.get('/performance', (_req: Request, res: Response) => { + res.json({ status: 'success', data: metricsCollector.getPerformanceMetrics() }); +}); + +/** + * @openapi + * /api/v1/metrics/errors: + * get: + * summary: Get raw error metrics + * tags: [Metrics] + */ +router.get('/errors', (_req: Request, res: Response) => { + res.json({ status: 'success', data: metricsCollector.getErrorMetrics() }); +}); + +/** + * @openapi + * /api/v1/metrics/business: + * get: + * summary: Get raw business event metrics + * tags: [Metrics] + */ +router.get('/business', (_req: Request, res: Response) => { + res.json({ status: 'success', data: metricsCollector.getBusinessMetrics() }); +}); + +/** + * @openapi + * /api/v1/metrics/reset: + * post: + * summary: Reset all collected metrics + * tags: [Metrics] + */ +router.post('/reset', (_req: Request, res: Response) => { + metricsCollector.reset(); + res.json({ status: 'success', message: 'Metrics reset successfully' }); +}); + +export default router; diff --git a/backend/tests/metrics.test.ts b/backend/tests/metrics.test.ts new file mode 100644 index 00000000..bc3edb0a --- /dev/null +++ b/backend/tests/metrics.test.ts @@ -0,0 +1,368 @@ +/** + * Unit tests for MetricsCollector and metrics routes. + * + * Coverage targets: + * - MetricsCollector: recordRequest, recordError, recordEvent, getSummary, + * getPerformanceMetrics, getErrorMetrics, getBusinessMetrics, reset, + * ring-buffer trimming, slow-request warning + * - metrics.routes: GET /, GET /performance, GET /errors, GET /business, + * POST /reset + */ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { MetricsCollector } from '../src/metrics/MetricsCollector.js'; + +// ── Mock logger so tests don't write to disk ────────────────────────────────── +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +jest.mock('../src/utils/logger.js', () => ({ + __esModule: true, + default: mockLogger, + auditLogger: { info: jest.fn() }, +})); + +// ─── MetricsCollector unit tests ───────────────────────────────────────────── + +describe('MetricsCollector', () => { + let collector: MetricsCollector; + + beforeEach(() => { + // Fresh instance for each test — avoids cross-test pollution. + collector = new MetricsCollector(); + }); + + // ── recordRequest ──────────────────────────────────────────────────────────── + + describe('recordRequest', () => { + it('stores a performance metric entry', () => { + collector.recordRequest('GET', '/api/v1/courses', 120, 200); + const metrics = collector.getPerformanceMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].method).toBe('GET'); + expect(metrics[0].route).toBe('/api/v1/courses'); + expect(metrics[0].durationMs).toBe(120); + expect(metrics[0].statusCode).toBe(200); + }); + + it('sets a valid ISO timestamp', () => { + collector.recordRequest('POST', '/api/v1/auth', 50, 201); + const ts = collector.getPerformanceMetrics()[0].timestamp; + expect(new Date(ts).toISOString()).toBe(ts); + }); + + it('accumulates multiple requests', () => { + collector.recordRequest('GET', '/a', 10, 200); + collector.recordRequest('GET', '/b', 20, 200); + collector.recordRequest('DELETE', '/c', 30, 204); + expect(collector.getPerformanceMetrics()).toHaveLength(3); + }); + + it('logs a warning for slow requests (>1000 ms)', () => { + // Verify the slow-request path doesn't throw and still records the metric. + expect(() => collector.recordRequest('GET', '/slow', 1500, 200)).not.toThrow(); + expect(collector.getPerformanceMetrics()[0].durationMs).toBe(1500); + }); + + it('does NOT warn for fast requests', () => { + mockLogger.warn.mockClear(); + collector.recordRequest('GET', '/fast', 200, 200); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + }); + + // ── recordError ────────────────────────────────────────────────────────────── + + describe('recordError', () => { + it('stores an error metric entry', () => { + collector.recordError('ValidationError', 'Invalid email', 400); + const errors = collector.getErrorMetrics(); + expect(errors).toHaveLength(1); + expect(errors[0].type).toBe('ValidationError'); + expect(errors[0].message).toBe('Invalid email'); + expect(errors[0].statusCode).toBe(400); + }); + + it('works without a statusCode', () => { + collector.recordError('UnhandledRejection', 'Promise rejected'); + const errors = collector.getErrorMetrics(); + expect(errors[0].statusCode).toBeUndefined(); + }); + + it('sets a valid ISO timestamp', () => { + collector.recordError('TypeError', 'Cannot read property'); + const ts = collector.getErrorMetrics()[0].timestamp; + expect(new Date(ts).toISOString()).toBe(ts); + }); + + it('records the error without throwing', () => { + expect(() => collector.recordError('DBError', 'Connection refused')).not.toThrow(); + expect(collector.getErrorMetrics()[0].type).toBe('DBError'); + }); + }); + + // ── recordEvent ────────────────────────────────────────────────────────────── + + describe('recordEvent', () => { + it('stores a business metric entry', () => { + collector.recordEvent('user.registered', { plan: 'free' }); + const events = collector.getBusinessMetrics(); + expect(events).toHaveLength(1); + expect(events[0].event).toBe('user.registered'); + expect(events[0].metadata).toEqual({ plan: 'free' }); + }); + + it('works without metadata', () => { + collector.recordEvent('certificate.issued'); + const events = collector.getBusinessMetrics(); + expect(events[0].metadata).toBeUndefined(); + }); + + it('sets a valid ISO timestamp', () => { + collector.recordEvent('course.enrolled'); + const ts = collector.getBusinessMetrics()[0].timestamp; + expect(new Date(ts).toISOString()).toBe(ts); + }); + + it('records the event without throwing', () => { + expect(() => collector.recordEvent('user.registered')).not.toThrow(); + expect(collector.getBusinessMetrics()[0].event).toBe('user.registered'); + }); + }); + + // ── getSummary ─────────────────────────────────────────────────────────────── + + describe('getSummary', () => { + it('returns zero counts when no metrics recorded', () => { + const summary = collector.getSummary(); + expect(summary.performance.totalRequests).toBe(0); + expect(summary.performance.averageDurationMs).toBe(0); + expect(summary.errors.totalErrors).toBe(0); + expect(summary.business.totalEvents).toBe(0); + }); + + it('aggregates request counts by route', () => { + collector.recordRequest('GET', '/courses', 100, 200); + collector.recordRequest('GET', '/courses', 200, 200); + collector.recordRequest('POST', '/courses', 150, 201); + const { requestsByRoute } = collector.getSummary().performance; + expect(requestsByRoute['GET /courses']).toBe(2); + expect(requestsByRoute['POST /courses']).toBe(1); + }); + + it('aggregates request counts by status code', () => { + collector.recordRequest('GET', '/a', 50, 200); + collector.recordRequest('GET', '/b', 50, 200); + collector.recordRequest('GET', '/c', 50, 404); + const { requestsByStatus } = collector.getSummary().performance; + expect(requestsByStatus['200']).toBe(2); + expect(requestsByStatus['404']).toBe(1); + }); + + it('calculates average duration correctly', () => { + collector.recordRequest('GET', '/a', 100, 200); + collector.recordRequest('GET', '/b', 200, 200); + const { averageDurationMs } = collector.getSummary().performance; + expect(averageDurationMs).toBe(150); + }); + + it('aggregates errors by type', () => { + collector.recordError('ValidationError', 'bad input'); + collector.recordError('ValidationError', 'bad input 2'); + collector.recordError('DBError', 'timeout'); + const { errorsByType } = collector.getSummary().errors; + expect(errorsByType['ValidationError']).toBe(2); + expect(errorsByType['DBError']).toBe(1); + }); + + it('aggregates business events by name', () => { + collector.recordEvent('user.registered'); + collector.recordEvent('user.registered'); + collector.recordEvent('certificate.issued'); + const { eventsByName } = collector.getSummary().business; + expect(eventsByName['user.registered']).toBe(2); + expect(eventsByName['certificate.issued']).toBe(1); + }); + + it('includes system metrics', () => { + const { system } = collector.getSummary(); + expect(typeof system.uptimeSeconds).toBe('number'); + expect(typeof system.memoryUsageMB).toBe('number'); + expect(typeof system.cpuUserMs).toBe('number'); + expect(system.uptimeSeconds).toBeGreaterThanOrEqual(0); + }); + + it('includes a collectedAt ISO timestamp', () => { + const { collectedAt } = collector.getSummary(); + expect(new Date(collectedAt).toISOString()).toBe(collectedAt); + }); + }); + + // ── reset ──────────────────────────────────────────────────────────────────── + + describe('reset', () => { + it('clears all metric categories', () => { + collector.recordRequest('GET', '/a', 50, 200); + collector.recordError('Error', 'oops'); + collector.recordEvent('user.registered'); + + collector.reset(); + + expect(collector.getPerformanceMetrics()).toHaveLength(0); + expect(collector.getErrorMetrics()).toHaveLength(0); + expect(collector.getBusinessMetrics()).toHaveLength(0); + }); + + it('resets summary counts to zero', () => { + collector.recordRequest('GET', '/a', 50, 200); + collector.reset(); + const summary = collector.getSummary(); + expect(summary.performance.totalRequests).toBe(0); + expect(summary.errors.totalErrors).toBe(0); + expect(summary.business.totalEvents).toBe(0); + }); + }); + + // ── ring-buffer trimming ───────────────────────────────────────────────────── + + describe('ring-buffer trimming', () => { + it('does not exceed maxEntries for performance metrics', () => { + const small = new MetricsCollector(5); + for (let i = 0; i < 10; i++) { + small.recordRequest('GET', `/route-${i}`, 10, 200); + } + expect(small.getPerformanceMetrics()).toHaveLength(5); + }); + + it('does not exceed maxEntries for error metrics', () => { + const small = new MetricsCollector(3); + for (let i = 0; i < 6; i++) { + small.recordError('Error', `msg-${i}`); + } + expect(small.getErrorMetrics()).toHaveLength(3); + }); + + it('does not exceed maxEntries for business metrics', () => { + const small = new MetricsCollector(4); + for (let i = 0; i < 8; i++) { + small.recordEvent(`event.${i}`); + } + expect(small.getBusinessMetrics()).toHaveLength(4); + }); + + it('keeps the most recent entries after trimming', () => { + const small = new MetricsCollector(2); + small.recordRequest('GET', '/old', 10, 200); + small.recordRequest('GET', '/newer', 20, 200); + small.recordRequest('GET', '/newest', 30, 200); + const metrics = small.getPerformanceMetrics(); + expect(metrics.map((m) => m.route)).toEqual(['/newer', '/newest']); + }); + }); + + // ── getters return copies ──────────────────────────────────────────────────── + + describe('immutable getters', () => { + it('getPerformanceMetrics returns a copy, not the internal array', () => { + collector.recordRequest('GET', '/a', 10, 200); + const copy = collector.getPerformanceMetrics(); + copy.push({ method: 'DELETE', route: '/injected', durationMs: 0, statusCode: 500, timestamp: '' }); + expect(collector.getPerformanceMetrics()).toHaveLength(1); + }); + }); +}); + +// ─── Metrics routes integration tests ──────────────────────────────────────── + +import express from 'express'; +import request from 'supertest'; +import metricsRouter from '../src/routes/metrics.routes.js'; +import metricsCollector from '../src/metrics/MetricsCollector.js'; + +describe('Metrics Routes', () => { + const app = express(); + app.use(express.json()); + app.use('/metrics', metricsRouter); + + beforeEach(() => { + metricsCollector.reset(); + }); + + describe('GET /metrics', () => { + it('returns 200 with a summary object', async () => { + const res = await request(app).get('/metrics'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + expect(res.body.data).toHaveProperty('performance'); + expect(res.body.data).toHaveProperty('errors'); + expect(res.body.data).toHaveProperty('business'); + expect(res.body.data).toHaveProperty('system'); + }); + }); + + describe('GET /metrics/performance', () => { + it('returns an empty array when no requests recorded', async () => { + const res = await request(app).get('/metrics/performance'); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it('returns recorded performance entries', async () => { + metricsCollector.recordRequest('GET', '/courses', 100, 200); + const res = await request(app).get('/metrics/performance'); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].route).toBe('/courses'); + }); + }); + + describe('GET /metrics/errors', () => { + it('returns an empty array when no errors recorded', async () => { + const res = await request(app).get('/metrics/errors'); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it('returns recorded error entries', async () => { + metricsCollector.recordError('ValidationError', 'bad input', 400); + const res = await request(app).get('/metrics/errors'); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].type).toBe('ValidationError'); + }); + }); + + describe('GET /metrics/business', () => { + it('returns an empty array when no events recorded', async () => { + const res = await request(app).get('/metrics/business'); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it('returns recorded business events', async () => { + metricsCollector.recordEvent('user.registered', { plan: 'pro' }); + const res = await request(app).get('/metrics/business'); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].event).toBe('user.registered'); + }); + }); + + describe('POST /metrics/reset', () => { + it('clears all metrics and returns success', async () => { + metricsCollector.recordRequest('GET', '/a', 50, 200); + metricsCollector.recordError('Error', 'oops'); + metricsCollector.recordEvent('user.registered'); + + const res = await request(app).post('/metrics/reset'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + + // Verify data is cleared + const summary = await request(app).get('/metrics'); + expect(summary.body.data.performance.totalRequests).toBe(0); + expect(summary.body.data.errors.totalErrors).toBe(0); + expect(summary.body.data.business.totalEvents).toBe(0); + }); + }); +});