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:
@@ -11,6 +11,12 @@ import { withRetry } from '../../common/helpers/retry.helper';
|
||||
import { withTimeout } from '../../common/helpers/timeout.helper';
|
||||
import { RequestLogger } from '../../common/helpers/request-logger.helper';
|
||||
import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
|
||||
import {
|
||||
AppErrorCode,
|
||||
buildNormalizedError,
|
||||
isAppErrorCode,
|
||||
resolveProviderPublicMessage,
|
||||
} from '../../common/constants/error-messages';
|
||||
|
||||
/**
|
||||
* Abstract base for all provider adapters.
|
||||
@@ -121,15 +127,32 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
||||
}
|
||||
|
||||
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
|
||||
return {
|
||||
code: partial.code ?? 'PROVIDER_ERROR',
|
||||
message: partial.message ?? 'Provider request failed',
|
||||
const code = this.toProviderErrorCode(partial.code);
|
||||
const publicMessage =
|
||||
code === 'PROVIDER_ERROR'
|
||||
? resolveProviderPublicMessage(partial.providerMessage ?? partial.message)
|
||||
: {
|
||||
message: partial.message ?? 'Provider request failed',
|
||||
messageFa: partial.messageFa,
|
||||
};
|
||||
|
||||
return buildNormalizedError(code, {
|
||||
message: publicMessage.message,
|
||||
messageFa: publicMessage.messageFa,
|
||||
providerMessage: partial.providerMessage,
|
||||
providerCode: partial.providerCode,
|
||||
providerTrackingCode: partial.providerTrackingCode,
|
||||
details: partial.details,
|
||||
conflict: partial.conflict,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private toProviderErrorCode(code?: string): AppErrorCode {
|
||||
if (!code) return 'PROVIDER_ERROR';
|
||||
if (isAppErrorCode(code)) return code;
|
||||
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') return 'PROVIDER_TIMEOUT';
|
||||
if (code === 'ECONNRESET' || code === 'NETWORK_ERROR') return 'PROVIDER_NETWORK_ERROR';
|
||||
return 'PROVIDER_ERROR';
|
||||
}
|
||||
|
||||
protected formatProviderError(
|
||||
@@ -139,12 +162,12 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
||||
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>,
|
||||
): Error {
|
||||
const message = providerMessage?.trim() || fallbackMessage;
|
||||
const code = providerCode?.trim() || 'PROVIDER_ERROR';
|
||||
const upstreamCode = providerCode?.trim();
|
||||
const normalized = this.normalizeError({
|
||||
code,
|
||||
message,
|
||||
code: upstreamCode,
|
||||
message: fallbackMessage,
|
||||
providerMessage: message,
|
||||
providerCode: code,
|
||||
providerCode: upstreamCode,
|
||||
...extras,
|
||||
});
|
||||
const err = new Error(message);
|
||||
@@ -166,10 +189,23 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
||||
}
|
||||
}
|
||||
|
||||
return this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
const providerCode = error.code ?? String(error.response?.status ?? 'NETWORK_ERROR');
|
||||
const code = error.response ? 'PROVIDER_BAD_RESPONSE' : providerCode;
|
||||
const normalized = this.normalizeError({
|
||||
code,
|
||||
message: error.message,
|
||||
providerMessage: error.message,
|
||||
providerCode,
|
||||
details: [
|
||||
{
|
||||
field: 'providerStatus',
|
||||
constraints: [String(error.response?.status ?? providerCode)],
|
||||
},
|
||||
],
|
||||
});
|
||||
const err = new Error(error.message);
|
||||
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
|
||||
return err;
|
||||
}
|
||||
|
||||
protected abstract callProvider(
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import {
|
||||
AppErrorCode,
|
||||
buildNormalizedError,
|
||||
} from '../../common/constants/error-messages';
|
||||
import { AmitisAuthServiceConfig } from '../../config/configuration';
|
||||
import { GeneralTokenDocument } from '../schemas/general-token.schema';
|
||||
import { GeneralTokenService } from '../services/general-token.service';
|
||||
@@ -209,14 +213,14 @@ export class AmitisProvider {
|
||||
loginStatus === 2
|
||||
? 'AMITIS username or password is invalid for this service account'
|
||||
: `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`,
|
||||
loginStatus === 2 ? 'AUTH_INVALID_CREDENTIALS' : 'AUTH_REJECTED',
|
||||
loginStatus === 2 ? 'PROVIDER_AUTH_FAILED' : 'PROVIDER_REJECTED',
|
||||
);
|
||||
}
|
||||
|
||||
if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) {
|
||||
throw this.createAuthError(
|
||||
`AMITIS login rejected (LoginStatus=${loginStatus})`,
|
||||
'AUTH_REJECTED',
|
||||
'PROVIDER_REJECTED',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,13 +271,28 @@ export class AmitisProvider {
|
||||
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
|
||||
}
|
||||
|
||||
private createAuthError(message: string, code: string): Error {
|
||||
const normalized: NormalizedErrorDto = {
|
||||
code,
|
||||
private createAuthError(message: string, code: AppErrorCode): Error {
|
||||
const normalized = buildNormalizedError(code, {
|
||||
message,
|
||||
providerMessage: message,
|
||||
providerCode: code,
|
||||
};
|
||||
});
|
||||
const err = new Error(message);
|
||||
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
|
||||
return err;
|
||||
}
|
||||
|
||||
private createProviderAuthError(message: string, error: unknown): Error {
|
||||
const providerCode =
|
||||
error instanceof AxiosError
|
||||
? String(error.response?.status ?? error.code ?? 'NETWORK_ERROR')
|
||||
: undefined;
|
||||
const providerMessage = error instanceof Error ? error.message : String(error);
|
||||
const normalized = buildNormalizedError('PROVIDER_AUTH_FAILED', {
|
||||
message,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
});
|
||||
const err = new Error(message);
|
||||
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
|
||||
return err;
|
||||
@@ -290,16 +309,17 @@ export class AmitisProvider {
|
||||
error.response?.data !== undefined
|
||||
? JSON.stringify(error.response.data).slice(0, 500)
|
||||
: undefined;
|
||||
return new Error(
|
||||
return this.createProviderAuthError(
|
||||
body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new Error(`${message}: ${error.message}`);
|
||||
return this.createProviderAuthError(`${message}: ${error.message}`, error);
|
||||
}
|
||||
|
||||
return new Error(`${message}: ${String(error)}`);
|
||||
return this.createProviderAuthError(`${message}: ${String(error)}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
|
||||
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { resolveProviderPublicMessage } from '../../common/constants/error-messages';
|
||||
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
|
||||
import { GeneralTokenService } from '../services/general-token.service';
|
||||
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
|
||||
@@ -191,19 +192,22 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
|
||||
}
|
||||
|
||||
private createProviderError(providerMessage: string, providerCode: string): Error {
|
||||
const publicMessage = resolveProviderPublicMessage(providerMessage);
|
||||
const error = new Error(providerMessage);
|
||||
(
|
||||
error as Error & {
|
||||
normalizedError: {
|
||||
code: string;
|
||||
message: string;
|
||||
messageFa: string;
|
||||
providerMessage: string;
|
||||
providerCode: string;
|
||||
};
|
||||
}
|
||||
).normalizedError = {
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: providerMessage,
|
||||
message: publicMessage.message,
|
||||
messageFa: publicMessage.messageFa,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { InquiryException } from '../../common/exceptions/inquiry.exception';
|
||||
import { ProviderFactory } from '../factory/provider.factory';
|
||||
import { translateError } from '../../common/helpers/translate-error.helper';
|
||||
import { buildNormalizedError } from '../../common/constants/error-messages';
|
||||
|
||||
export interface OrchestrationResult<T> {
|
||||
data: T;
|
||||
@@ -30,8 +32,9 @@ export class ProviderOrchestratorService {
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new InquiryException('No providers available for inquiry', {
|
||||
code: 'NO_PROVIDERS',
|
||||
message: `No enabled providers configured for ${inquiryType}`,
|
||||
...buildNormalizedError('NO_PROVIDERS', {
|
||||
message: `No enabled providers configured for ${inquiryType}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,12 +80,11 @@ export class ProviderOrchestratorService {
|
||||
const lastProvider = providers[providers.length - 1]!.name;
|
||||
throw new InquiryException(
|
||||
errors.map((error) => error.message).join('; '),
|
||||
{
|
||||
code: 'ALL_PROVIDERS_FAILED',
|
||||
buildNormalizedError('ALL_PROVIDERS_FAILED', {
|
||||
message: errors.map((error) => error.message).join('; '),
|
||||
providerMessage: lastError.providerMessage ?? lastError.message,
|
||||
providerCode: lastError.providerCode ?? lastError.code,
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
lastProvider,
|
||||
);
|
||||
@@ -90,14 +92,13 @@ export class ProviderOrchestratorService {
|
||||
|
||||
private extractError(error: unknown): NormalizedErrorDto {
|
||||
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
||||
return (error as { normalizedError: NormalizedErrorDto }).normalizedError;
|
||||
return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError);
|
||||
}
|
||||
if (error instanceof InquiryException) {
|
||||
return error.normalizedError;
|
||||
return translateError(error.normalizedError);
|
||||
}
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
return buildNormalizedError('UNKNOWN_ERROR', {
|
||||
message: error instanceof Error ? error.message : 'Unknown provider error',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user