From 9b265b8f467f7f21a7f0924d6835b1ee5b89df21 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 22 May 2026 18:17:57 +0700 Subject: [PATCH] test: add unit tests for BaseRemoteDataServices functionality and OtelAdapter instrumentation --- .../base-remote.data-services.test.ts | 263 ++++++++++++ .../src/observability/otel.adapter.test.ts | 385 ++++++++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 packages/core-api/src/data-services/base-remote.data-services.test.ts create mode 100644 packages/core-api/src/observability/otel.adapter.test.ts diff --git a/packages/core-api/src/data-services/base-remote.data-services.test.ts b/packages/core-api/src/data-services/base-remote.data-services.test.ts new file mode 100644 index 0000000..b19104b --- /dev/null +++ b/packages/core-api/src/data-services/base-remote.data-services.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import type { BaseEntity } from './types'; +import { CommonRemoteDataServices } from './common-remote.data-services'; + +// ─── Mock AxiosInstance ───────────────────────────────────────── + +function createMockHttpClient(): AxiosInstance { + return { + request: vi.fn().mockResolvedValue({ + data: [{ id: '1', name: 'Test' }], + status: 200, + }), + // Satisfy the AxiosInstance shape (unused properties) + defaults: {} as AxiosInstance['defaults'], + interceptors: { + request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() }, + }, + getUri: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + head: vi.fn(), + options: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + postForm: vi.fn(), + putForm: vi.fn(), + patchForm: vi.fn(), + } as unknown as AxiosInstance; +} + +// ─── Test Entity ──────────────────────────────────────────────── + +interface TestEntity extends BaseEntity { + bookingCode: string; + customerName: string; +} + +// ─── Tests ────────────────────────────────────────────────────── + +describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => { + let mockClient: AxiosInstance; + let services: CommonRemoteDataServices; + + beforeEach(() => { + mockClient = createMockHttpClient(); + services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + }); + }); + + // ── URL Interpolation ───────────────────────────────────────── + + describe('URL interpolation', () => { + it('getMany() uses the base URL without params', async () => { + await services.getMany(); + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/bookings', + method: 'GET', + }), + ); + }); + + it('getOne() replaces :id in the URL template', async () => { + await services.getOne('123'); + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/bookings/123', + method: 'GET', + }), + ); + }); + + it('getOne() encodes special characters in ID', async () => { + await services.getOne('hello world'); + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/bookings/hello%20world', + }), + ); + }); + + it('activate() resolves to /:id/active', async () => { + await services.activate('42'); + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/bookings/42/active', + method: 'PATCH', + }), + ); + }); + + it('confirmProcessTransaction() resolves to /:id/confirm-data', async () => { + await services.confirmProcessTransaction('99'); + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/bookings/99/confirm-data', + method: 'PATCH', + }), + ); + }); + }); + + // ── Header Injection ────────────────────────────────────────── + + describe('header injection', () => { + it('injects ex-module-key from moduleKey config', async () => { + await services.getMany(); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-key']).toBe('BOOKING'); + }); + + it('injects ex-module-action from the descriptor action', async () => { + await services.getMany(); // VIEW action + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-action']).toBe('VIEW'); + }); + + it('injects CREATE action for create()', async () => { + await services.create({ bookingCode: 'BK001', customerName: 'Test' } as Partial); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-action']).toBe('CREATE'); + }); + + it('injects EDIT action for edit()', async () => { + await services.edit('42', { customerName: 'Updated' } as Partial); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-action']).toBe('EDIT'); + }); + + it('injects DELETE action for delete()', async () => { + await services.delete('42'); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-action']).toBe('DELETE'); + }); + + it('omits ex-module-key when moduleKey is not configured', async () => { + const noKeyServices = new CommonRemoteDataServices(mockClient, { + apiUrl: '/items', + }); + + await noKeyServices.getMany(); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-key']).toBeUndefined(); + // But action is always present + expect(requestArg.headers['ex-module-action']).toBe('VIEW'); + }); + + it('preserves caller-provided headers alongside injected ones', async () => { + await services.getMany({ + headers: { 'X-Custom-Header': 'custom-value' }, + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-key']).toBe('BOOKING'); + expect(requestArg.headers['ex-module-action']).toBe('VIEW'); + expect(requestArg.headers['X-Custom-Header']).toBe('custom-value'); + }); + }); + + // ── Telemetry Context ───────────────────────────────────────── + + describe('telemetry context passthrough', () => { + it('passes telemetryContext from getMany() config to the Axios request', async () => { + const telemetryContext = { + customSpanName: 'booking.list.fetch', + tags: { region: 'asia' }, + pushEventOnSuccess: 'booking_list_loaded', + }; + + await services.getMany({ telemetryContext } as AxiosRequestConfig); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.telemetryContext).toEqual(telemetryContext); + }); + + it('passes telemetryContext via customRequest()', async () => { + const telemetryContext = { + customSpanName: 'custom.tax.calculate', + tags: { business: 'tax' }, + }; + + await services.customRequest({ + url: '/bookings/42/calculate-tax', + method: 'POST', + data: { items: [] }, + telemetryContext, + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.telemetryContext).toEqual(telemetryContext); + }); + + it('customRequest() still injects ex-module-key header', async () => { + await services.customRequest({ + url: '/bookings/42/calculate-tax', + method: 'POST', + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.headers['ex-module-key']).toBe('BOOKING'); + }); + }); + + // ── Response Shape ──────────────────────────────────────────── + + describe('response shape', () => { + it('returns { data, status } from the Axios response', async () => { + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, + status: 200, + }); + + const result = await services.getOne('42'); + + expect(result).toEqual({ + data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' }, + status: 200, + }); + }); + + it('create() passes data in the Axios request config', async () => { + const newBooking: Partial = { + bookingCode: 'BK001', + customerName: 'Bob', + }; + + await services.create(newBooking); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.method).toBe('POST'); + expect(requestArg.url).toBe('/bookings'); + expect(requestArg.data).toEqual(newBooking); + }); + }); + + // ── Batch Operations ────────────────────────────────────────── + + describe('batch operations', () => { + it('batchDelete() sends ids in data payload', async () => { + await services.batchDelete(['1', '2', '3']); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.url).toBe('/bookings/batch-delete'); + expect(requestArg.data).toEqual({ ids: ['1', '2', '3'] }); + }); + }); +}); diff --git a/packages/core-api/src/observability/otel.adapter.test.ts b/packages/core-api/src/observability/otel.adapter.test.ts new file mode 100644 index 0000000..c1de9c7 --- /dev/null +++ b/packages/core-api/src/observability/otel.adapter.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { SpanStatusCode } from '@opentelemetry/api'; +import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError, AxiosHeaders } from 'axios'; + +// ─── Mock @opentelemetry/api ──────────────────────────────────── + +const mockSpan = { + setAttribute: vi.fn(), + setStatus: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), +}; + +const mockTracer = { + startSpan: vi.fn(() => mockSpan), +}; + +vi.mock('@opentelemetry/api', () => ({ + trace: { + getTracer: vi.fn(() => mockTracer), + }, + SpanStatusCode: { + OK: 1, + ERROR: 2, + }, +})); + +// ─── Mock @grafana/faro-web-sdk ───────────────────────────────── + +vi.mock('@grafana/faro-web-sdk', () => ({ + LogLevel: { + DEBUG: 'debug', + ERROR: 'error', + }, +})); + +// ─── Mock getFaro() ───────────────────────────────────────────── + +const mockFaroApi = { + pushLog: vi.fn(), + pushError: vi.fn(), + pushEvent: vi.fn(), +}; + +const mockFaro = { api: mockFaroApi }; + +vi.mock('./setup', () => ({ + getFaro: vi.fn(() => mockFaro), +})); + +// ─── Import SUT after mocks ───────────────────────────────────── + +import { faroAdapter } from './otel.adapter'; + +// ─── Helpers ──────────────────────────────────────────────────── + +function createAxiosHeaders(headers: Record = {}): AxiosHeaders { + // AxiosHeaders-compatible plain object for testing + return headers as unknown as AxiosHeaders; +} + +function makeRequestConfig( + overrides: Partial & Record = {}, +): InternalAxiosRequestConfig { + return { + method: 'get', + url: '/bookings', + headers: createAxiosHeaders(), + ...overrides, + } as InternalAxiosRequestConfig; +} + +function makeAxiosResponse( + config: InternalAxiosRequestConfig, + overrides: Partial = {}, +): AxiosResponse { + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + ...overrides, + } as AxiosResponse; +} + +function makeAxiosError( + config: InternalAxiosRequestConfig | undefined, + status: number | undefined, + message = 'Request failed', +): AxiosError { + return { + isAxiosError: true, + name: 'AxiosError', + message, + config, + response: status ? { status, data: {}, headers: {}, statusText: 'Error', config } : undefined, + toJSON: () => ({}), + } as AxiosError; +} + +// ─── Tests ────────────────────────────────────────────────────── + +describe('faroAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── onRequestStart ──────────────────────────────────────────── + + describe('onRequestStart', () => { + it('pushes a Faro log with method and URL', () => { + const config = makeRequestConfig({ + method: 'post', + url: '/users', + }); + + faroAdapter.onRequestStart(config); + + expect(mockFaroApi.pushLog).toHaveBeenCalledOnce(); + expect(mockFaroApi.pushLog).toHaveBeenCalledWith( + ['[core-api] POST /users'], + expect.objectContaining({ + level: 'debug', + context: expect.objectContaining({ + 'http.method': 'POST', + 'http.url': '/users', + }), + }), + ); + }); + + it('extracts ex-module-key and ex-module-action into Faro context', () => { + const config = makeRequestConfig({ + headers: createAxiosHeaders({ + 'ex-module-key': 'BOOKING', + 'ex-module-action': 'VIEW', + }), + }); + + faroAdapter.onRequestStart(config); + + expect(mockFaroApi.pushLog).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + context: expect.objectContaining({ + 'module.key': 'BOOKING', + 'module.action': 'VIEW', + }), + }), + ); + }); + + it('includes telemetryContext.tags in Faro context', () => { + const config = makeRequestConfig({ + telemetryContext: { + tags: { region: 'asia', priority: 'high' }, + }, + }); + + faroAdapter.onRequestStart(config); + + expect(mockFaroApi.pushLog).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + context: expect.objectContaining({ + region: 'asia', + priority: 'high', + }), + }), + ); + }); + + it('creates a custom span when customSpanName is provided', () => { + const config = makeRequestConfig({ + method: 'get', + url: '/bookings', + headers: createAxiosHeaders({ + 'ex-module-key': 'BOOKING', + 'ex-module-action': 'VIEW', + }), + telemetryContext: { + customSpanName: 'booking.list.fetch', + tags: { feature: 'booking' }, + }, + }); + + faroAdapter.onRequestStart(config); + + expect(mockTracer.startSpan).toHaveBeenCalledWith('booking.list.fetch', { + attributes: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': '/bookings', + 'custom.module_key': 'BOOKING', + 'custom.module_action': 'VIEW', + }), + }); + + // Custom tags are attached with `custom.` prefix + expect(mockSpan.setAttribute).toHaveBeenCalledWith('custom.feature', 'booking'); + }); + + it('does NOT create a span when no customSpanName is provided', () => { + const config = makeRequestConfig(); + faroAdapter.onRequestStart(config); + + expect(mockTracer.startSpan).not.toHaveBeenCalled(); + }); + }); + + // ── onRequestEnd ────────────────────────────────────────────── + + describe('onRequestEnd', () => { + it('closes the custom span with OK status', () => { + // First create the span + const config = makeRequestConfig({ + telemetryContext: { customSpanName: 'test.span' }, + }); + faroAdapter.onRequestStart(config); + vi.clearAllMocks(); + + // Then end the request + const response = makeAxiosResponse(config, { status: 200 }); + faroAdapter.onRequestEnd(response); + + expect(mockSpan.setAttribute).toHaveBeenCalledWith('http.status_code', 200); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK }); + expect(mockSpan.end).toHaveBeenCalledOnce(); + }); + + it('pushes a Faro event when pushEventOnSuccess is configured', () => { + const config = makeRequestConfig({ + method: 'get', + url: '/bookings', + telemetryContext: { + pushEventOnSuccess: 'booking_list_loaded', + tags: { page: 1 }, + }, + }); + + const response = makeAxiosResponse(config, { status: 200 }); + faroAdapter.onRequestEnd(response); + + expect(mockFaroApi.pushEvent).toHaveBeenCalledWith( + 'booking_list_loaded', + expect.objectContaining({ + 'http.status_code': '200', + 'http.url': '/bookings', + page: '1', + }), + ); + }); + + it('does NOT push an event when no pushEventOnSuccess is configured', () => { + const config = makeRequestConfig(); + const response = makeAxiosResponse(config); + faroAdapter.onRequestEnd(response); + + expect(mockFaroApi.pushEvent).not.toHaveBeenCalled(); + }); + + it('detaches span reference after closing (prevents double-close on retry)', () => { + const config = makeRequestConfig({ + telemetryContext: { customSpanName: 'retry.test' }, + }); + faroAdapter.onRequestStart(config); + vi.clearAllMocks(); + + const response = makeAxiosResponse(config); + faroAdapter.onRequestEnd(response); + expect(mockSpan.end).toHaveBeenCalledOnce(); + + // Second call should NOT close the span again + vi.clearAllMocks(); + faroAdapter.onRequestEnd(response); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + // ── onRequestError ──────────────────────────────────────────── + + describe('onRequestError', () => { + it('pushes a Faro error with enriched context', () => { + const config = makeRequestConfig({ + method: 'post', + url: '/bookings', + headers: createAxiosHeaders({ + 'ex-module-key': 'BOOKING', + 'ex-module-action': 'CREATE', + }), + }); + + const error = makeAxiosError(config, 422, 'Validation failed'); + faroAdapter.onRequestError(error); + + expect(mockFaroApi.pushError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + type: 'api_error', + context: expect.objectContaining({ + 'http.method': 'POST', + 'http.url': '/bookings', + 'http.status_code': '422', + 'module.key': 'BOOKING', + 'module.action': 'CREATE', + 'error.message': 'Validation failed', + }), + }), + ); + }); + + it('pushes a Faro log at ERROR level', () => { + const config = makeRequestConfig({ url: '/users' }); + const error = makeAxiosError(config, 500, 'Internal Server Error'); + faroAdapter.onRequestError(error); + + expect(mockFaroApi.pushLog).toHaveBeenCalledWith( + ['[core-api] ERROR GET /users → 500'], + expect.objectContaining({ + level: 'error', + context: expect.objectContaining({ + 'http.status_code': '500', + 'error.message': 'Internal Server Error', + }), + }), + ); + }); + + it('closes the custom span with ERROR status', () => { + const config = makeRequestConfig({ + telemetryContext: { customSpanName: 'error.test' }, + }); + faroAdapter.onRequestStart(config); + vi.clearAllMocks(); + + const error = makeAxiosError(config, 500, 'Server Error'); + faroAdapter.onRequestError(error); + + expect(mockSpan.setAttribute).toHaveBeenCalledWith('http.status_code', 500); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SpanStatusCode.ERROR, + message: 'Server Error', + }); + expect(mockSpan.recordException).toHaveBeenCalledWith(error); + expect(mockSpan.end).toHaveBeenCalledOnce(); + }); + + it('handles undefined error.config gracefully (network timeout)', () => { + const error = makeAxiosError(undefined, undefined, 'Network Error'); + + // Should not throw + expect(() => faroAdapter.onRequestError(error)).not.toThrow(); + + // Should still push error with fallback values + expect(mockFaroApi.pushError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + context: expect.objectContaining({ + 'http.method': 'UNKNOWN', + 'http.url': '/', + 'http.status_code': '0', + }), + }), + ); + }); + + it('includes telemetryContext.tags in error Faro context', () => { + const config = makeRequestConfig({ + telemetryContext: { tags: { region: 'eu', critical: true } }, + }); + const error = makeAxiosError(config, 503, 'Service Unavailable'); + faroAdapter.onRequestError(error); + + expect(mockFaroApi.pushError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + context: expect.objectContaining({ + region: 'eu', + critical: 'true', + }), + }), + ); + }); + }); +});