test: add unit tests for BaseRemoteDataServices functionality and OtelAdapter instrumentation
This commit is contained in:
@@ -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<TestEntity>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockClient = createMockHttpClient();
|
||||||
|
services = new CommonRemoteDataServices<TestEntity>(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<typeof vi.fn>).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<typeof vi.fn>).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<TestEntity>);
|
||||||
|
|
||||||
|
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<TestEntity>);
|
||||||
|
|
||||||
|
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<TestEntity>(mockClient, {
|
||||||
|
apiUrl: '/items',
|
||||||
|
});
|
||||||
|
|
||||||
|
await noKeyServices.getMany();
|
||||||
|
|
||||||
|
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await services.getOne<TestEntity>('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<TestEntity> = {
|
||||||
|
bookingCode: 'BK001',
|
||||||
|
customerName: 'Bob',
|
||||||
|
};
|
||||||
|
|
||||||
|
await services.create(newBooking);
|
||||||
|
|
||||||
|
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][0];
|
||||||
|
expect(requestArg.url).toBe('/bookings/batch-delete');
|
||||||
|
expect(requestArg.data).toEqual({ ids: ['1', '2', '3'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user