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:
2026-06-29 17:25:19 +03:30
parent 63d41f4288
commit 45ef396466
53 changed files with 1965 additions and 1178 deletions

40
test/support/app.ts Normal file
View 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;
}

View 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
View 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;
},
};
}

View 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;
}

View 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' },
},
];

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