forked from Shared/esg
feat: add Persian error translations and comprehensive test suite
Implement normalized error handling with Persian translations across all exception types, replace legacy NestJS exceptions with AppException, and add unit, integration, and smoke tests. - Add error catalog with gateway-owned codes and Persian messages - Introduce AppException wrapping normalized error envelopes - Add translateError helper for automatic messageFa population - Remove claims module and update provider error normalization - Add unit tests for error contracts and helper functions - Add integration tests for admin and inquiry endpoints - Add smoke tests for real provider connectivity - Add test support utilities (auth mocks, assertions, app factory)
This commit is contained in:
221
test/admin/admin.e2e-spec.ts
Normal file
221
test/admin/admin.e2e-spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request = require('supertest');
|
||||
import { AuthController } from '../../src/auth/auth.controller';
|
||||
import { AuthService } from '../../src/auth/auth.service';
|
||||
import { UsersController } from '../../src/users/users.controller';
|
||||
import { UsersService } from '../../src/users/users.service';
|
||||
import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../src/auth/guards/roles.guard';
|
||||
import { ClientType } from '../../src/common/enums/client-type.enum';
|
||||
import { Role } from '../../src/common/enums/role.enum';
|
||||
import { AppException } from '../../src/common/exceptions/app-exception';
|
||||
import { createHttpTestApp } from '../support/app';
|
||||
import { bearerTokenGuard, passGuard } from '../support/auth';
|
||||
import { expectErrorEnvelope } from '../support/assertions';
|
||||
|
||||
function userResponse(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'client-1',
|
||||
fullName: 'Client One',
|
||||
username: 'client-one',
|
||||
email: 'client@example.com',
|
||||
role: Role.USER,
|
||||
clientType: ClientType.EXTERNAL,
|
||||
appName: 'client-app',
|
||||
clientName: 'Client One',
|
||||
isActive: true,
|
||||
isBlocked: false,
|
||||
allowedInquiries: [],
|
||||
requestLimitPerMinute: 60,
|
||||
requestLimitPerDay: 10000,
|
||||
totalRequests: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('admin API', () => {
|
||||
let app: INestApplication;
|
||||
const authService = {
|
||||
login: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
getProfile: jest.fn(),
|
||||
};
|
||||
const usersService = {
|
||||
create: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
update: jest.fn(),
|
||||
block: jest.fn(),
|
||||
unblock: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createHttpTestApp(
|
||||
{
|
||||
controllers: [AuthController, UsersController],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
],
|
||||
},
|
||||
(builder) =>
|
||||
builder
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue(bearerTokenGuard())
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue(passGuard()),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
authService.login.mockResolvedValue({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 900,
|
||||
});
|
||||
usersService.create.mockResolvedValue(userResponse());
|
||||
usersService.update.mockResolvedValue(userResponse({ fullName: 'Updated Client' }));
|
||||
usersService.block.mockResolvedValue(userResponse({ isBlocked: true }));
|
||||
usersService.unblock.mockResolvedValue(userResponse({ isBlocked: false }));
|
||||
usersService.resetPassword.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('logs in with valid credentials', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ username: 'admin', password: 'SecureP@ssw0rd' })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns invalid credentials using the normalized error envelope', async () => {
|
||||
authService.login.mockRejectedValueOnce(new AppException('INVALID_CREDENTIALS'));
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ username: 'admin', password: 'WrongP@ssw0rd' })
|
||||
.expect(401);
|
||||
|
||||
expectErrorEnvelope(response, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('rejects unauthorized admin access', async () => {
|
||||
const response = await request(app.getHttpServer()).post('/users').send({}).expect(401);
|
||||
|
||||
expectErrorEnvelope(response, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('rejects expired JWTs', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', 'Bearer expired')
|
||||
.send({})
|
||||
.expect(401);
|
||||
|
||||
expectErrorEnvelope(response, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('creates a client', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({
|
||||
fullName: 'Client One',
|
||||
username: 'client-one',
|
||||
email: 'client@example.com',
|
||||
password: 'SecureP@ssw0rd',
|
||||
role: Role.USER,
|
||||
clientType: ClientType.EXTERNAL,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({ id: 'client-1', username: 'client-one' });
|
||||
});
|
||||
|
||||
it('updates a client', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch('/users/client-1')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({ fullName: 'Updated Client' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.fullName).toBe('Updated Client');
|
||||
});
|
||||
|
||||
it('disables and enables a client through block/unblock', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/users/client-1/block')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.expect(200)
|
||||
.expect(({ body }) => expect(body.isBlocked).toBe(true));
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/users/client-1/unblock')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.expect(200)
|
||||
.expect(({ body }) => expect(body.isBlocked).toBe(false));
|
||||
});
|
||||
|
||||
it('rejects invalid client configuration at the DTO layer', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({
|
||||
fullName: 'Client One',
|
||||
username: 'client-one',
|
||||
email: 'not-an-email',
|
||||
password: 'short',
|
||||
role: 'BAD_ROLE',
|
||||
clientType: ClientType.EXTERNAL,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expectErrorEnvelope(response, 'VALIDATION_ERROR');
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns duplicate client errors', async () => {
|
||||
usersService.create.mockRejectedValueOnce(new AppException('USERNAME_OR_EMAIL_EXISTS'));
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({
|
||||
fullName: 'Client One',
|
||||
username: 'client-one',
|
||||
email: 'client@example.com',
|
||||
password: 'SecureP@ssw0rd',
|
||||
role: Role.USER,
|
||||
clientType: ClientType.EXTERNAL,
|
||||
})
|
||||
.expect(409);
|
||||
|
||||
expectErrorEnvelope(response, 'USERNAME_OR_EMAIL_EXISTS');
|
||||
});
|
||||
|
||||
it('validates reset-password requests', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch('/users/client-1/reset-password')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({ newPassword: 'short' })
|
||||
.expect(400);
|
||||
|
||||
expectErrorEnvelope(response, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it.todo('delete client once the admin API exposes DELETE /users/:id');
|
||||
});
|
||||
131
test/integration/inquiry.e2e-spec.ts
Normal file
131
test/integration/inquiry.e2e-spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request = require('supertest');
|
||||
import { InquiryController } from '../../src/inquiry/inquiry.controller';
|
||||
import { InquiryService } from '../../src/inquiry/inquiry.service';
|
||||
import { ProviderOrchestratorService } from '../../src/providers/strategy/provider-orchestrator.service';
|
||||
import { InquiryLogService } from '../../src/logging/inquiry-log.service';
|
||||
import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard';
|
||||
import { InquiryAccessGuard } from '../../src/auth/guards/inquiry-access.guard';
|
||||
import { UserRateLimitGuard } from '../../src/rate-limit/user-rate-limit.guard';
|
||||
import { inquiryEndpointCases } from '../support/inquiry-endpoints';
|
||||
import { createHttpTestApp } from '../support/app';
|
||||
import { authenticatedGuard, passGuard } from '../support/auth';
|
||||
import {
|
||||
normalizedProviderError,
|
||||
providerErrorCases,
|
||||
successfulProviderResult,
|
||||
} from '../support/mock-provider';
|
||||
import { expectErrorEnvelope, expectSuccessEnvelope } from '../support/assertions';
|
||||
|
||||
describe('inquiry endpoints (integration)', () => {
|
||||
let app: INestApplication;
|
||||
const orchestrator = {
|
||||
executeWithFallback: jest.fn(),
|
||||
};
|
||||
const inquiryLogs = {
|
||||
create: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createHttpTestApp(
|
||||
{
|
||||
controllers: [InquiryController],
|
||||
providers: [
|
||||
InquiryService,
|
||||
{ provide: ProviderOrchestratorService, useValue: orchestrator },
|
||||
{ provide: InquiryLogService, useValue: inquiryLogs },
|
||||
],
|
||||
},
|
||||
(builder) =>
|
||||
builder
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue(authenticatedGuard())
|
||||
.overrideGuard(InquiryAccessGuard)
|
||||
.useValue(passGuard())
|
||||
.overrideGuard(UserRateLimitGuard)
|
||||
.useValue(passGuard()),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
orchestrator.executeWithFallback.mockResolvedValue(successfulProviderResult());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe.each(inquiryEndpointCases)('$name', (endpoint) => {
|
||||
it('returns a successful response and maps provider data', async () => {
|
||||
orchestrator.executeWithFallback.mockResolvedValueOnce(
|
||||
successfulProviderResult({ raw: { mapped: true, inquiryType: endpoint.inquiryType } }),
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(endpoint.path)
|
||||
.send(endpoint.validPayload)
|
||||
.expect(201);
|
||||
|
||||
expectSuccessEnvelope(response);
|
||||
expect(response.body.data).toMatchObject({
|
||||
mapped: true,
|
||||
inquiryType: endpoint.inquiryType,
|
||||
});
|
||||
expect(orchestrator.executeWithFallback).toHaveBeenCalledWith(
|
||||
endpoint.inquiryType,
|
||||
expect.objectContaining(endpoint.validPayload),
|
||||
expect.objectContaining({ requestId: 'test-request-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns validation errors without calling providers', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(endpoint.path)
|
||||
.send(endpoint.invalidPayload)
|
||||
.expect(400);
|
||||
|
||||
expectErrorEnvelope(response, 'VALIDATION_ERROR');
|
||||
if (endpoint.name === 'person inquiry') {
|
||||
expect(response.body.error).toMatchObject({
|
||||
message: 'nationalCode must be exactly 10 digits',
|
||||
messageFa: 'کد ملی باید دقیقا ۱۰ رقم باشد',
|
||||
details: [
|
||||
{
|
||||
field: 'nationalCode',
|
||||
constraints: ['nationalCode must be exactly 10 digits'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
expect(orchestrator.executeWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed JSON without calling providers', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(endpoint.path)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"broken":')
|
||||
.expect(400);
|
||||
|
||||
expect(orchestrator.executeWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each(providerErrorCases)('$name', (providerCase) => {
|
||||
it('maps provider errors into the normalized response envelope', async () => {
|
||||
orchestrator.executeWithFallback.mockRejectedValueOnce(
|
||||
normalizedProviderError(providerCase.error),
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(endpoint.path)
|
||||
.send(endpoint.validPayload)
|
||||
.expect(201);
|
||||
|
||||
expectErrorEnvelope(response, providerCase.code);
|
||||
expect(response.body.data).toBeNull();
|
||||
expect(response.body.error.providerCode).toBe(providerCase.error.providerCode);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
78
test/smoke/providers.smoke-spec.ts
Normal file
78
test/smoke/providers.smoke-spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import axios from 'axios';
|
||||
import { lookup } from 'dns/promises';
|
||||
import * as tls from 'tls';
|
||||
|
||||
const smokeEnabled = process.env.ESG_SMOKE_ENABLED === 'true';
|
||||
const describeSmoke = smokeEnabled ? describe : describe.skip;
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required for smoke tests`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describeSmoke('real provider smoke tests', () => {
|
||||
let gatewayBaseUrl: string;
|
||||
let inquiryPath: string;
|
||||
let accessToken: string;
|
||||
let payload: Record<string, unknown>;
|
||||
let providerUrl: URL;
|
||||
|
||||
beforeAll(() => {
|
||||
gatewayBaseUrl = requireEnv('SMOKE_GATEWAY_BASE_URL');
|
||||
inquiryPath = process.env.SMOKE_INQUIRY_PATH ?? '/inquiry/person';
|
||||
accessToken = requireEnv('SMOKE_ACCESS_TOKEN');
|
||||
payload = JSON.parse(requireEnv('SMOKE_INQUIRY_PAYLOAD')) as Record<string, unknown>;
|
||||
providerUrl = new URL(process.env.SMOKE_PROVIDER_URL ?? gatewayBaseUrl);
|
||||
});
|
||||
|
||||
it('resolves provider DNS', async () => {
|
||||
await expect(lookup(providerUrl.hostname)).resolves.toMatchObject({
|
||||
address: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('negotiates SSL/TLS when provider uses HTTPS', async () => {
|
||||
if (providerUrl.protocol !== 'https:') {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = tls.connect(
|
||||
{
|
||||
host: providerUrl.hostname,
|
||||
port: Number(providerUrl.port || 443),
|
||||
servername: providerUrl.hostname,
|
||||
timeout: 10000,
|
||||
},
|
||||
() => {
|
||||
socket.end();
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
socket.on('error', reject);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
reject(new Error('TLS probe timed out'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('reaches the gateway and performs a real successful inquiry', async () => {
|
||||
const response = await axios.post(`${gatewayBaseUrl}${inquiryPath}`, payload, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
timeout: Number(process.env.SMOKE_TIMEOUT_MS ?? 30000),
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
expect(response.status).toBeLessThan(300);
|
||||
expect(response.data).toMatchObject({
|
||||
success: true,
|
||||
trackingCode: expect.any(String),
|
||||
data: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
40
test/support/app.ts
Normal file
40
test/support/app.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { ModuleMetadata } from '@nestjs/common/interfaces';
|
||||
import { Test, TestingModuleBuilder } from '@nestjs/testing';
|
||||
import { AllExceptionsFilter } from '../../src/common/filters/all-exceptions.filter';
|
||||
import { AppException } from '../../src/common/exceptions/app-exception';
|
||||
import { buildValidationErrorResponse } from '../../src/common/helpers/validation-error.helper';
|
||||
|
||||
export async function createHttpTestApp(
|
||||
metadata: ModuleMetadata,
|
||||
configure: (builder: TestingModuleBuilder) => TestingModuleBuilder = (builder) => builder,
|
||||
): Promise<INestApplication> {
|
||||
const moduleRef = await configure(Test.createTestingModule(metadata)).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
|
||||
app.use((req: { requestId?: string }, _res: unknown, next: () => void) => {
|
||||
req.requestId = 'test-request-id';
|
||||
next();
|
||||
});
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
exceptionFactory: (errors) => {
|
||||
const { normalizedError } = buildValidationErrorResponse(errors);
|
||||
|
||||
return new AppException('VALIDATION_ERROR', {
|
||||
message: normalizedError.message,
|
||||
messageFa: normalizedError.messageFa,
|
||||
details: normalizedError.details,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
23
test/support/assertions.ts
Normal file
23
test/support/assertions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Response } from 'supertest';
|
||||
|
||||
export function expectSuccessEnvelope(response: Response): void {
|
||||
expect(response.body).toMatchObject({
|
||||
success: true,
|
||||
trackingCode: expect.any(String),
|
||||
provider: expect.any(String),
|
||||
duration: expect.any(Number),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function expectErrorEnvelope(response: Response, code: string): void {
|
||||
expect(response.body).toMatchObject({
|
||||
success: false,
|
||||
trackingCode: expect.any(String),
|
||||
error: {
|
||||
code,
|
||||
message: expect.any(String),
|
||||
messageFa: expect.any(String),
|
||||
},
|
||||
});
|
||||
}
|
||||
63
test/support/auth.ts
Normal file
63
test/support/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthenticatedUser } from '../../src/auth/interfaces/authenticated-user.interface';
|
||||
import { ClientType } from '../../src/common/enums/client-type.enum';
|
||||
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
|
||||
import { Role } from '../../src/common/enums/role.enum';
|
||||
import { AppException } from '../../src/common/exceptions/app-exception';
|
||||
|
||||
export function makeAuthenticatedUser(
|
||||
overrides: Partial<AuthenticatedUser> = {},
|
||||
): AuthenticatedUser {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
role: Role.SUPER_ADMIN,
|
||||
clientType: ClientType.INTERNAL,
|
||||
appName: 'test-admin',
|
||||
allowedInquiries: Object.values(InquiryType),
|
||||
requestLimitPerMinute: 100,
|
||||
requestLimitPerDay: 10000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function authenticatedGuard(user = makeAuthenticatedUser()): CanActivate {
|
||||
return {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: AuthenticatedUser }>();
|
||||
request.user = user;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function bearerTokenGuard(user = makeAuthenticatedUser()): CanActivate {
|
||||
return {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ headers: Record<string, string | undefined>; user?: AuthenticatedUser }>();
|
||||
const authorization = request.headers.authorization;
|
||||
|
||||
if (!authorization) {
|
||||
throw new AppException('UNAUTHORIZED');
|
||||
}
|
||||
|
||||
if (authorization === 'Bearer expired') {
|
||||
throw new AppException('UNAUTHORIZED', { message: 'jwt expired' });
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function passGuard(): CanActivate {
|
||||
return {
|
||||
canActivate(): boolean {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
38
test/support/client-config.factory.ts
Normal file
38
test/support/client-config.factory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
|
||||
|
||||
export interface TestClientConfig {
|
||||
clientId: string;
|
||||
apiKey: string;
|
||||
providerBaseUrl: string;
|
||||
credentials: Record<string, string>;
|
||||
requiresVpn: boolean;
|
||||
allowedInquiries: InquiryType[];
|
||||
}
|
||||
|
||||
export function createClientConfig(
|
||||
overrides: Partial<TestClientConfig> = {},
|
||||
): TestClientConfig {
|
||||
return {
|
||||
clientId: 'default-client',
|
||||
apiKey: 'test-api-key',
|
||||
providerBaseUrl: 'https://provider.test',
|
||||
credentials: {
|
||||
username: 'provider-user',
|
||||
password: 'provider-password',
|
||||
},
|
||||
requiresVpn: false,
|
||||
allowedInquiries: Object.values(InquiryType),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClientConfig(
|
||||
clients: TestClientConfig[],
|
||||
clientId: string,
|
||||
): TestClientConfig {
|
||||
const client = clients.find((candidate) => candidate.clientId === clientId);
|
||||
if (!client) {
|
||||
throw new Error(`No test client config found for ${clientId}`);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
79
test/support/inquiry-endpoints.ts
Normal file
79
test/support/inquiry-endpoints.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
|
||||
|
||||
export interface InquiryEndpointCase {
|
||||
name: string;
|
||||
inquiryType: InquiryType;
|
||||
path: string;
|
||||
validPayload: Record<string, unknown>;
|
||||
invalidPayload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const inquiryEndpointCases: InquiryEndpointCase[] = [
|
||||
{
|
||||
name: 'person inquiry',
|
||||
inquiryType: InquiryType.PERSON,
|
||||
path: '/inquiry/person',
|
||||
validPayload: { nationalCode: '0012345678', birthDate: '1378-11-24', dateHasPostfix: 0 },
|
||||
invalidPayload: { nationalCode: '123', birthDate: 'bad-date' },
|
||||
},
|
||||
{
|
||||
name: 'real estate inquiry',
|
||||
inquiryType: InquiryType.REAL_ESTATE,
|
||||
path: '/inquiry/realEstate',
|
||||
validPayload: { nationalCode: '5098961130', postalCode: '1349689554' },
|
||||
invalidPayload: { nationalCode: 'bad', postalCode: '134' },
|
||||
},
|
||||
{
|
||||
name: 'sheba inquiry',
|
||||
inquiryType: InquiryType.SHEBA,
|
||||
path: '/inquiry/sheba',
|
||||
validPayload: {
|
||||
accountOwnerType: '1',
|
||||
nationalCode: '4311402422',
|
||||
legalId: '',
|
||||
sheba: 'IR800560611828005105117001',
|
||||
},
|
||||
invalidPayload: { nationalCode: '4311402422', sheba: 'bad' },
|
||||
},
|
||||
{
|
||||
name: 'shahkar inquiry',
|
||||
inquiryType: InquiryType.SHAHKAR,
|
||||
path: '/inquiry/shahkar',
|
||||
validPayload: { nationalCode: '4311402422', mobileNo: '09226187419' },
|
||||
invalidPayload: { nationalCode: '4311402422', mobileNo: '123' },
|
||||
},
|
||||
{
|
||||
name: 'postal code inquiry',
|
||||
inquiryType: InquiryType.POSTAL_CODE,
|
||||
path: '/inquiry/postalCode',
|
||||
validPayload: { postalCode: '1234567890' },
|
||||
invalidPayload: { postalCode: '123' },
|
||||
},
|
||||
{
|
||||
name: 'policy by chassis inquiry',
|
||||
inquiryType: InquiryType.POLICY_BY_CHASSIS,
|
||||
path: '/inquiry/policyByChassis',
|
||||
validPayload: { chassisNo: 'NAAM01E15HK123456' },
|
||||
invalidPayload: { chassisNo: '' },
|
||||
},
|
||||
{
|
||||
name: 'policy by plate inquiry',
|
||||
inquiryType: InquiryType.POLICY_BY_PLATE,
|
||||
path: '/inquiry/policyByPlate',
|
||||
validPayload: {
|
||||
nationalCode: '4311402422',
|
||||
plk1: '12',
|
||||
plk2: 'ب',
|
||||
plk3: '345',
|
||||
plksrl: '67',
|
||||
},
|
||||
invalidPayload: { nationalCode: 'bad', plk1: '1', plk2: '', plk3: '3', plksrl: '' },
|
||||
},
|
||||
{
|
||||
name: 'policy by national code inquiry',
|
||||
inquiryType: InquiryType.POLICY_BY_NATIONAL_CODE,
|
||||
path: '/inquiry/policyByNationalCode',
|
||||
validPayload: { nationalCode: '4311402422' },
|
||||
invalidPayload: { nationalCode: 'bad' },
|
||||
},
|
||||
];
|
||||
60
test/support/mock-provider.ts
Normal file
60
test/support/mock-provider.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NormalizedErrorDto } from '../../src/common/dto/normalized-error.dto';
|
||||
import { ProviderName } from '../../src/common/enums/provider-name.enum';
|
||||
import { buildNormalizedError } from '../../src/common/constants/error-messages';
|
||||
|
||||
export function successfulProviderResult(data: unknown = { raw: { ok: true } }) {
|
||||
return {
|
||||
data,
|
||||
provider: ProviderName.HAMTA,
|
||||
duration: 12,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizedProviderError(error: NormalizedErrorDto): Error {
|
||||
const err = new Error(error.message);
|
||||
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = error;
|
||||
return err;
|
||||
}
|
||||
|
||||
export const providerErrorCases = [
|
||||
{
|
||||
name: 'provider timeout',
|
||||
code: 'PROVIDER_TIMEOUT',
|
||||
error: buildNormalizedError('PROVIDER_TIMEOUT', {
|
||||
message: 'Provider request timed out',
|
||||
providerCode: 'ETIMEDOUT',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'provider HTTP 4xx',
|
||||
code: 'PROVIDER_BAD_RESPONSE',
|
||||
error: buildNormalizedError('PROVIDER_BAD_RESPONSE', {
|
||||
message: 'Provider returned HTTP 400',
|
||||
providerCode: '400',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'provider HTTP 5xx',
|
||||
code: 'PROVIDER_BAD_RESPONSE',
|
||||
error: buildNormalizedError('PROVIDER_BAD_RESPONSE', {
|
||||
message: 'Provider returned HTTP 500',
|
||||
providerCode: '500',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'provider business error',
|
||||
code: 'INQUIRY_NO_MATCH',
|
||||
error: buildNormalizedError('INQUIRY_NO_MATCH', {
|
||||
message: 'Inquiry returned no matching result',
|
||||
providerCode: 'NO_MATCH',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'malformed provider response',
|
||||
code: 'PROVIDER_BAD_RESPONSE',
|
||||
error: buildNormalizedError('PROVIDER_BAD_RESPONSE', {
|
||||
message: 'Provider response could not be parsed',
|
||||
providerCode: 'MALFORMED_RESPONSE',
|
||||
}),
|
||||
},
|
||||
];
|
||||
145
test/unit/helpers-and-mappers.spec.ts
Normal file
145
test/unit/helpers-and-mappers.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { maskPayload } from '../../src/common/helpers/mask-payload.helper';
|
||||
import { buildValidationErrorResponse } from '../../src/common/helpers/validation-error.helper';
|
||||
import {
|
||||
getShahkarProviderError,
|
||||
isShahkarSuccess,
|
||||
normalizeShahkarFields,
|
||||
parseShahkarInqueryResult,
|
||||
} from '../../src/common/helpers/shahkar-response.helper';
|
||||
import { getSayahProviderError } from '../../src/common/helpers/sayah-response.helper';
|
||||
import { getCentInsurProviderError } from '../../src/common/helpers/centinsur-response.helper';
|
||||
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
|
||||
import { ClientType } from '../../src/common/enums/client-type.enum';
|
||||
import { Role } from '../../src/common/enums/role.enum';
|
||||
import { UserMapper } from '../../src/users/mappers/user.mapper';
|
||||
import {
|
||||
createClientConfig,
|
||||
resolveClientConfig,
|
||||
} from '../support/client-config.factory';
|
||||
|
||||
describe('unit helpers and mappers', () => {
|
||||
it('masks sensitive payload fields recursively without mutating safe fields', () => {
|
||||
expect(
|
||||
maskPayload({
|
||||
nationalCode: '0012345678',
|
||||
nested: { password: 'secret', safe: 'visible' },
|
||||
amount: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
nationalCode: '***MASKED***',
|
||||
nested: { password: '***MASKED***', safe: 'visible' },
|
||||
amount: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Shahkar provider no-match responses to a stable internal code', () => {
|
||||
const fields = {
|
||||
response: '600',
|
||||
result: 'NotIdentifiedException',
|
||||
comment: '',
|
||||
requestId: 'provider-tracking',
|
||||
};
|
||||
|
||||
expect(isShahkarSuccess(fields)).toBe(false);
|
||||
expect(getShahkarProviderError(fields)).toMatchObject({
|
||||
code: 'INQUIRY_NO_MATCH',
|
||||
providerTrackingCode: 'provider-tracking',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SOAP Shahkar response fields', () => {
|
||||
const fields = normalizeShahkarFields(parseShahkarInqueryResult(`
|
||||
<ShahkarInqueryResult>
|
||||
<Response>200</Response>
|
||||
<Result>OK: matched</Result>
|
||||
</ShahkarInqueryResult>
|
||||
`) as Record<string, unknown>);
|
||||
|
||||
expect(fields).toMatchObject({ response: '200', result: 'OK: matched' });
|
||||
expect(isShahkarSuccess(fields)).toBe(true);
|
||||
});
|
||||
|
||||
it('maps Sayah and CentInsur business errors', () => {
|
||||
expect(
|
||||
getSayahProviderError({
|
||||
ReturnValue: false,
|
||||
HasError: false,
|
||||
Errors: [{ Code: 'SHEBA', Message: 'Mismatch' }],
|
||||
}),
|
||||
).toMatchObject({ code: 'SHEBA_MISMATCH' });
|
||||
|
||||
expect(
|
||||
getCentInsurProviderError(
|
||||
{ IsSucceed: true, Result: { Result: false, ErrorMessage: null } },
|
||||
InquiryType.REAL_ESTATE,
|
||||
),
|
||||
).toMatchObject({
|
||||
code: 'INQUIRY_NO_MATCH',
|
||||
message: 'No property ownership found for the provided national code and postal code',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps user documents without leaking secrets', () => {
|
||||
const dto = UserMapper.toResponseDto({
|
||||
_id: { toString: () => 'user-1' },
|
||||
fullName: 'Admin User',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
role: Role.SUPER_ADMIN,
|
||||
clientType: ClientType.INTERNAL,
|
||||
appName: 'gateway',
|
||||
clientName: 'Gateway',
|
||||
isActive: true,
|
||||
isBlocked: false,
|
||||
allowedInquiries: [],
|
||||
requestLimitPerMinute: 10,
|
||||
requestLimitPerDay: 100,
|
||||
totalRequests: 3,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00Z'),
|
||||
password: 'should-not-leak',
|
||||
} as never);
|
||||
|
||||
expect(dto).toMatchObject({ id: 'user-1', username: 'admin' });
|
||||
expect(dto).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('resolves client-specific credentials and network configuration', () => {
|
||||
const vpnClient = createClientConfig({
|
||||
clientId: 'vpn-client',
|
||||
requiresVpn: true,
|
||||
providerBaseUrl: 'https://vpn-only.provider.test',
|
||||
credentials: { username: 'vpn-user', password: 'vpn-password' },
|
||||
});
|
||||
|
||||
expect(resolveClientConfig([createClientConfig(), vpnClient], 'vpn-client')).toMatchObject({
|
||||
providerBaseUrl: 'https://vpn-only.provider.test',
|
||||
credentials: { username: 'vpn-user', password: 'vpn-password' },
|
||||
requiresVpn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns one prioritized validation error instead of duplicate constraints', () => {
|
||||
const { normalizedError } = buildValidationErrorResponse([
|
||||
{
|
||||
property: 'nationalCode',
|
||||
constraints: {
|
||||
isLength: 'nationalCode must be longer than or equal to 10 characters',
|
||||
matches: 'nationalCode must be exactly 10 digits',
|
||||
},
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(normalizedError).toMatchObject({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'nationalCode must be exactly 10 digits',
|
||||
messageFa: 'کد ملی باید دقیقا ۱۰ رقم باشد',
|
||||
details: [
|
||||
{
|
||||
field: 'nationalCode',
|
||||
constraints: ['nationalCode must be exactly 10 digits'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user