test(api): add unit tests for analytics, metrics, notifications, payments, and search modules
New test coverage for infrastructure and presentation layers across multiple modules including Momo/ZaloPay payment services, Typesense search repository, listing indexer, and notification handlers. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { type Counter, type Gauge, type Histogram } from 'prom-client';
|
||||
import { MetricsService } from '../metrics.service';
|
||||
|
||||
describe('MetricsService', () => {
|
||||
let service: MetricsService;
|
||||
let mockListingsCreatedCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockPaymentsProcessedCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockActiveSubscriptionsGauge: { set: ReturnType<typeof vi.fn> };
|
||||
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
||||
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingsCreatedCounter = { inc: vi.fn() };
|
||||
mockPaymentsProcessedCounter = { inc: vi.fn() };
|
||||
mockActiveSubscriptionsGauge = { set: vi.fn() };
|
||||
mockSearchQueriesCounter = { inc: vi.fn() };
|
||||
mockRequestDurationHistogram = { observe: vi.fn() };
|
||||
mockHttpRequestsCounter = { inc: vi.fn() };
|
||||
|
||||
service = new MetricsService(
|
||||
mockListingsCreatedCounter as unknown as Counter,
|
||||
mockPaymentsProcessedCounter as unknown as Counter,
|
||||
mockActiveSubscriptionsGauge as unknown as Gauge,
|
||||
mockSearchQueriesCounter as unknown as Counter,
|
||||
mockRequestDurationHistogram as unknown as Histogram,
|
||||
mockHttpRequestsCounter as unknown as Counter,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordListingCreated increments listingsCreatedCounter with the given category', () => {
|
||||
service.recordListingCreated('apartment');
|
||||
|
||||
expect(mockListingsCreatedCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockListingsCreatedCounter.inc).toHaveBeenCalledWith({ category: 'apartment' });
|
||||
});
|
||||
|
||||
it('recordPaymentProcessed increments paymentsProcessedCounter with status and method', () => {
|
||||
service.recordPaymentProcessed('success', 'vnpay');
|
||||
|
||||
expect(mockPaymentsProcessedCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockPaymentsProcessedCounter.inc).toHaveBeenCalledWith({
|
||||
status: 'success',
|
||||
method: 'vnpay',
|
||||
});
|
||||
});
|
||||
|
||||
it('setActiveSubscriptions sets the gauge with plan and count', () => {
|
||||
service.setActiveSubscriptions('pro', 42);
|
||||
|
||||
expect(mockActiveSubscriptionsGauge.set).toHaveBeenCalledOnce();
|
||||
expect(mockActiveSubscriptionsGauge.set).toHaveBeenCalledWith({ plan: 'pro' }, 42);
|
||||
});
|
||||
|
||||
it('recordSearchQuery increments searchQueriesCounter with collection and type', () => {
|
||||
service.recordSearchQuery('properties', 'geo');
|
||||
|
||||
expect(mockSearchQueriesCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockSearchQueriesCounter.inc).toHaveBeenCalledWith({
|
||||
collection: 'properties',
|
||||
type: 'geo',
|
||||
});
|
||||
});
|
||||
|
||||
it('recordHttpRequest observes requestDurationHistogram with correct labels and duration', () => {
|
||||
service.recordHttpRequest('GET', '/api/listings', 200, 0.123);
|
||||
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledOnce();
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledWith(
|
||||
{ method: 'GET', route: '/api/listings', status_code: '200' },
|
||||
0.123,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordHttpRequest increments httpRequestsCounter with correct labels', () => {
|
||||
service.recordHttpRequest('POST', '/api/payments', 201, 0.456);
|
||||
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledWith({
|
||||
method: 'POST',
|
||||
route: '/api/payments',
|
||||
status_code: '201',
|
||||
});
|
||||
});
|
||||
|
||||
it('recordHttpRequest calls both histogram and counter with the same labels', () => {
|
||||
service.recordHttpRequest('DELETE', '/api/listings/1', 204, 0.05);
|
||||
|
||||
const expectedLabels = { method: 'DELETE', route: '/api/listings/1', status_code: '204' };
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledWith(expectedLabels, 0.05);
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledWith(expectedLabels);
|
||||
});
|
||||
|
||||
it('recordHttpRequest converts numeric statusCode to string in labels', () => {
|
||||
service.recordHttpRequest('GET', '/api/health', 503, 0.001);
|
||||
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status_code: '503' }),
|
||||
0.001,
|
||||
);
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status_code: '503' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { type CallHandler, type ExecutionContext } from '@nestjs/common';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { MetricsService } from '../../../infrastructure/metrics.service';
|
||||
import { HttpMetricsInterceptor } from '../http-metrics.interceptor';
|
||||
|
||||
describe('HttpMetricsInterceptor', () => {
|
||||
let interceptor: HttpMetricsInterceptor;
|
||||
let mockMetricsService: { recordHttpRequest: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockMetricsService = { recordHttpRequest: vi.fn() };
|
||||
interceptor = new HttpMetricsInterceptor(mockMetricsService as unknown as MetricsService);
|
||||
});
|
||||
|
||||
function createContext(
|
||||
requestOverrides: Record<string, unknown> = {},
|
||||
responseOverrides: Record<string, unknown> = {},
|
||||
): ExecutionContext {
|
||||
const mockRequest = {
|
||||
method: 'GET',
|
||||
path: '/api/listings',
|
||||
route: { path: '/api/listings/:id' },
|
||||
...requestOverrides,
|
||||
};
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
...responseOverrides,
|
||||
};
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
getResponse: () => mockResponse,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
it('records metrics on successful request', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'GET', path: '/api/listings', route: { path: '/api/listings/:id' } },
|
||||
{ statusCode: 200 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledOnce();
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/api/listings/:id',
|
||||
200,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('records metrics on error request', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'POST', path: '/api/payments', route: { path: '/api/payments' } },
|
||||
{ statusCode: 500 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => throwError(() => new Error('test')) };
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledOnce();
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/api/payments',
|
||||
500,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses request.path when request.route is undefined', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'GET', path: '/api/search', route: undefined },
|
||||
{ statusCode: 200 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/api/search',
|
||||
200,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses request.route.path when available', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'GET', path: '/api/listings/abc123', route: { path: '/api/listings/:id' } },
|
||||
{ statusCode: 200 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/api/listings/:id',
|
||||
200,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a non-negative duration in seconds', async () => {
|
||||
const context = createContext({}, { statusCode: 200 });
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
const [, , , durationSeconds] = mockMetricsService.recordHttpRequest.mock.calls[0] as [
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
expect(durationSeconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user