import { describe, it, expect, vi, beforeEach } from "vitest"; import { TelemetryInterceptor } from "./telemetry.interceptor"; import { TelemetryService } from "./telemetry.service"; import type { ExecutionContext, CallHandler } from "@nestjs/common"; import type { Span } from "@opentelemetry/api"; import { of, throwError } from "rxjs"; import { lastValueFrom } from "rxjs"; describe("TelemetryInterceptor", () => { let interceptor: TelemetryInterceptor; let telemetryService: TelemetryService; let mockSpan: Span; let mockContext: ExecutionContext; let mockHandler: CallHandler; beforeEach(() => { // Mock span mockSpan = { end: vi.fn(), setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn(), setStatus: vi.fn(), updateName: vi.fn(), isRecording: vi.fn().mockReturnValue(true), recordException: vi.fn(), spanContext: vi.fn().mockReturnValue({ traceId: "test-trace-id", spanId: "test-span-id", }), } as unknown as Span; // Mock telemetry service telemetryService = { startSpan: vi.fn().mockReturnValue(mockSpan), recordException: vi.fn(), getTracer: vi.fn(), onModuleInit: vi.fn(), onModuleDestroy: vi.fn(), } as unknown as TelemetryService; // Mock execution context mockContext = { switchToHttp: vi.fn().mockReturnValue({ getRequest: vi.fn().mockReturnValue({ method: "GET", url: "/api/test", path: "/api/test", }), getResponse: vi.fn().mockReturnValue({ statusCode: 200, setHeader: vi.fn(), headersSent: false, writableEnded: false, }), }), getClass: vi.fn().mockReturnValue({ name: "TestController" }), getHandler: vi.fn().mockReturnValue({ name: "testHandler" }), } as unknown as ExecutionContext; interceptor = new TelemetryInterceptor(telemetryService); }); describe("intercept", () => { it("should create a span for HTTP request", async () => { mockHandler = { handle: vi.fn().mockReturnValue(of({ data: "test" })), } as unknown as CallHandler; await lastValueFrom(interceptor.intercept(mockContext, mockHandler)); expect(telemetryService.startSpan).toHaveBeenCalledWith( "GET /api/test", expect.objectContaining({ attributes: expect.objectContaining({ "http.request.method": "GET", "url.path": "/api/test", }), }) ); }); it("should set http.status_code attribute on success", async () => { mockHandler = { handle: vi.fn().mockReturnValue(of({ data: "test" })), } as unknown as CallHandler; await lastValueFrom(interceptor.intercept(mockContext, mockHandler)); expect(mockSpan.setAttribute).toHaveBeenCalledWith("http.response.status_code", 200); expect(mockSpan.end).toHaveBeenCalled(); }); it("should add trace context to response headers", async () => { mockHandler = { handle: vi.fn().mockReturnValue(of({ data: "test" })), } as unknown as CallHandler; const mockResponse = mockContext.switchToHttp().getResponse(); await lastValueFrom(interceptor.intercept(mockContext, mockHandler)); expect(mockResponse.setHeader).toHaveBeenCalledWith("x-trace-id", "test-trace-id"); }); it("should not set trace header when response is already committed", async () => { const committedResponseContext = { ...mockContext, switchToHttp: vi.fn().mockReturnValue({ getRequest: vi.fn().mockReturnValue({ method: "GET", url: "/api/test", path: "/api/test", }), getResponse: vi.fn().mockReturnValue({ statusCode: 200, setHeader: vi.fn(), headersSent: true, writableEnded: true, }), }), } as unknown as ExecutionContext; mockHandler = { handle: vi.fn().mockReturnValue(of({ data: "test" })), } as unknown as CallHandler; const committedResponse = committedResponseContext.switchToHttp().getResponse(); await lastValueFrom(interceptor.intercept(committedResponseContext, mockHandler)); expect(committedResponse.setHeader).not.toHaveBeenCalled(); }); it("should record exception on error", async () => { const error = new Error("Test error"); mockHandler = { handle: vi.fn().mockReturnValue(throwError(() => error)), } as unknown as CallHandler; await expect(lastValueFrom(interceptor.intercept(mockContext, mockHandler))).rejects.toThrow( "Test error" ); expect(telemetryService.recordException).toHaveBeenCalledWith(mockSpan, error); expect(mockSpan.end).toHaveBeenCalled(); }); it("should end span even if error occurs", async () => { const error = new Error("Test error"); mockHandler = { handle: vi.fn().mockReturnValue(throwError(() => error)), } as unknown as CallHandler; await expect( lastValueFrom(interceptor.intercept(mockContext, mockHandler)) ).rejects.toThrow(); expect(mockSpan.end).toHaveBeenCalled(); }); it("should handle different HTTP methods", async () => { const postContext = { ...mockContext, switchToHttp: vi.fn().mockReturnValue({ getRequest: vi.fn().mockReturnValue({ method: "POST", url: "/api/test", path: "/api/test", }), getResponse: vi.fn().mockReturnValue({ statusCode: 201, setHeader: vi.fn(), }), }), } as unknown as ExecutionContext; mockHandler = { handle: vi.fn().mockReturnValue(of({ data: "created" })), } as unknown as CallHandler; await lastValueFrom(interceptor.intercept(postContext, mockHandler)); expect(telemetryService.startSpan).toHaveBeenCalledWith( "POST /api/test", expect.objectContaining({ attributes: expect.objectContaining({ "http.request.method": "POST", }), }) ); }); it("should set controller and handler attributes", async () => { mockHandler = { handle: vi.fn().mockReturnValue(of({ data: "test" })), } as unknown as CallHandler; await lastValueFrom(interceptor.intercept(mockContext, mockHandler)); expect(telemetryService.startSpan).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ attributes: expect.objectContaining({ "code.function": "testHandler", "code.namespace": "TestController", }), }) ); }); }); });