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:
Ho Ngoc Hai
2026-04-08 23:07:14 +07:00
parent 7fb25eb2b1
commit c9782fd48d
18 changed files with 2097 additions and 0 deletions

View File

@@ -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' }),
);
});
});

View File

@@ -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);
});
});