test: add unit tests for BaseRemoteDataServices functionality and OtelAdapter instrumentation

This commit is contained in:
Firman Ramdhani
2026-05-22 18:17:57 +07:00
parent 75827bfd71
commit 9b265b8f46
2 changed files with 648 additions and 0 deletions
@@ -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<string, string> = {}): AxiosHeaders {
// AxiosHeaders-compatible plain object for testing
return headers as unknown as AxiosHeaders;
}
function makeRequestConfig(
overrides: Partial<InternalAxiosRequestConfig> & Record<string, unknown> = {},
): InternalAxiosRequestConfig {
return {
method: 'get',
url: '/bookings',
headers: createAxiosHeaders(),
...overrides,
} as InternalAxiosRequestConfig;
}
function makeAxiosResponse(
config: InternalAxiosRequestConfig,
overrides: Partial<AxiosResponse> = {},
): 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',
}),
}),
);
});
});
});