forked from Shared/esg
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)
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import { HttpStatus } from '@nestjs/common';
|
|
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
|
|
|
export interface ErrorCatalogEntry {
|
|
status: HttpStatus;
|
|
message: string;
|
|
messageFa: string;
|
|
}
|
|
|
|
export const ERROR_CATALOG = {
|
|
PROVIDER_TIMEOUT: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider request timed out',
|
|
messageFa: 'درخواست به سرویسدهنده زمانبر شد',
|
|
},
|
|
PROVIDER_ERROR: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider request failed',
|
|
messageFa: 'خطا در سرویسدهنده',
|
|
},
|
|
PROVIDER_NETWORK_ERROR: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider network request failed',
|
|
messageFa: 'ارتباط با سرویسدهنده برقرار نشد',
|
|
},
|
|
PROVIDER_BAD_RESPONSE: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider returned an invalid response',
|
|
messageFa: 'پاسخ سرویسدهنده معتبر نیست',
|
|
},
|
|
PROVIDER_AUTH_FAILED: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider authentication failed',
|
|
messageFa: 'احراز هویت سرویسدهنده ناموفق بود',
|
|
},
|
|
PROVIDER_REJECTED: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider rejected the request',
|
|
messageFa: 'سرویسدهنده درخواست را رد کرد',
|
|
},
|
|
NO_PROVIDERS: {
|
|
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
|
message: 'No enabled providers configured for this inquiry',
|
|
messageFa: 'سرویسدهندهای برای این استعلام فعال نیست',
|
|
},
|
|
ALL_PROVIDERS_FAILED: {
|
|
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
|
message: 'All providers failed',
|
|
messageFa: 'تمامی سرویسدهندهها با خطا مواجه شدند',
|
|
},
|
|
UNKNOWN_ERROR: {
|
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
message: 'Unknown provider error',
|
|
messageFa: 'خطای ناشناخته در سرویسدهنده',
|
|
},
|
|
UNSUPPORTED_INQUIRY: {
|
|
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
|
message: 'Provider does not support this inquiry type',
|
|
messageFa: 'سرویسدهنده از این نوع استعلام پشتیبانی نمیکند',
|
|
},
|
|
API_ERROR: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Provider API request failed',
|
|
messageFa: 'خطا در فراخوانی API سرویسدهنده',
|
|
},
|
|
HTTP_ERROR: {
|
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
message: 'HTTP request failed',
|
|
messageFa: 'درخواست ناموفق بود',
|
|
},
|
|
INTERNAL_ERROR: {
|
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
message: 'Internal server error',
|
|
messageFa: 'خطای داخلی سرور',
|
|
},
|
|
NETWORK_ERROR: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Network request failed',
|
|
messageFa: 'خطا در اتصال به شبکه',
|
|
},
|
|
INQUIRY_FAILED: {
|
|
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
|
message: 'Inquiry failed',
|
|
messageFa: 'استعلام با خطا مواجه شد',
|
|
},
|
|
INQUIRY_NO_MATCH: {
|
|
status: HttpStatus.OK,
|
|
message: 'Inquiry returned no matching result',
|
|
messageFa: 'نتیجهای مطابق با اطلاعات وارد شده یافت نشد',
|
|
},
|
|
RECORD_NOT_FOUND: {
|
|
status: HttpStatus.OK,
|
|
message: 'Record not found',
|
|
messageFa: 'رکوردی یافت نشد',
|
|
},
|
|
SAYAH_ERROR: {
|
|
status: HttpStatus.BAD_GATEWAY,
|
|
message: 'Sayah inquiry failed',
|
|
messageFa: 'خطا در سرویس سیاح',
|
|
},
|
|
SHEBA_MISMATCH: {
|
|
status: HttpStatus.OK,
|
|
message: 'Submitted sheba does not match inquiry result',
|
|
messageFa: 'اطلاعات شبای وارد شده با نتیجه استعلام مطابقت ندارد',
|
|
},
|
|
VALIDATION_ERROR: {
|
|
status: HttpStatus.BAD_REQUEST,
|
|
message: 'Request validation failed',
|
|
messageFa: 'اطلاعات وارد شده معتبر نیست',
|
|
},
|
|
INVALID_CREDENTIALS: {
|
|
status: HttpStatus.UNAUTHORIZED,
|
|
message: 'Invalid credentials',
|
|
messageFa: 'نام کاربری یا رمز عبور صحیح نیست',
|
|
},
|
|
ACCOUNT_INACTIVE: {
|
|
status: HttpStatus.FORBIDDEN,
|
|
message: 'Account is inactive',
|
|
messageFa: 'حساب کاربری غیرفعال است',
|
|
},
|
|
ACCOUNT_BLOCKED: {
|
|
status: HttpStatus.FORBIDDEN,
|
|
message: 'Account is blocked',
|
|
messageFa: 'حساب کاربری مسدود شده است',
|
|
},
|
|
INVALID_REFRESH_TOKEN: {
|
|
status: HttpStatus.UNAUTHORIZED,
|
|
message: 'Invalid refresh token',
|
|
messageFa: 'توکن نوسازی نامعتبر است',
|
|
},
|
|
API_KEY_NOT_CONFIGURED: {
|
|
status: HttpStatus.UNAUTHORIZED,
|
|
message: 'API key authentication is not configured',
|
|
messageFa: 'احراز هویت با کلید API پیکربندی نشده است',
|
|
},
|
|
INVALID_API_KEY: {
|
|
status: HttpStatus.UNAUTHORIZED,
|
|
message: 'Invalid or missing API key',
|
|
messageFa: 'کلید API نامعتبر است یا ارسال نشده است',
|
|
},
|
|
UNAUTHORIZED: {
|
|
status: HttpStatus.UNAUTHORIZED,
|
|
message: 'Unauthorized',
|
|
messageFa: 'احراز هویت انجام نشده است',
|
|
},
|
|
AUTHENTICATION_REQUIRED: {
|
|
status: HttpStatus.FORBIDDEN,
|
|
message: 'Authentication required',
|
|
messageFa: 'احراز هویت الزامی است',
|
|
},
|
|
INSUFFICIENT_ROLE: {
|
|
status: HttpStatus.FORBIDDEN,
|
|
message: 'Insufficient role permissions',
|
|
messageFa: 'سطح دسترسی کافی نیست',
|
|
},
|
|
INSUFFICIENT_USER_MANAGEMENT_PERMISSION: {
|
|
status: HttpStatus.FORBIDDEN,
|
|
message: 'Insufficient permissions to manage users',
|
|
messageFa: 'دسترسی کافی برای مدیریت کاربران وجود ندارد',
|
|
},
|
|
SUPER_ADMIN_ROLE_REQUIRED: {
|
|
status: HttpStatus.FORBIDDEN,
|
|
message: 'Only SUPER_ADMIN can assign SUPER_ADMIN role',
|
|
messageFa: 'فقط مدیر ارشد میتواند نقش مدیر ارشد را اختصاص دهد',
|
|
},
|
|
USER_NOT_FOUND: {
|
|
status: HttpStatus.NOT_FOUND,
|
|
message: 'User not found',
|
|
messageFa: 'کاربر یافت نشد',
|
|
},
|
|
USERNAME_OR_EMAIL_EXISTS: {
|
|
status: HttpStatus.CONFLICT,
|
|
message: 'Username or email already exists',
|
|
messageFa: 'نام کاربری یا ایمیل قبلا ثبت شده است',
|
|
},
|
|
EMAIL_ALREADY_IN_USE: {
|
|
status: HttpStatus.CONFLICT,
|
|
message: 'Email already in use',
|
|
messageFa: 'این ایمیل قبلا استفاده شده است',
|
|
},
|
|
PASSWORD_TOO_SHORT: {
|
|
status: HttpStatus.CONFLICT,
|
|
message: 'Password must be at least 8 characters',
|
|
messageFa: 'رمز عبور باید حداقل ۸ کاراکتر باشد',
|
|
},
|
|
CANNOT_BLOCK_SELF: {
|
|
status: HttpStatus.BAD_REQUEST,
|
|
message: 'Cannot block your own account',
|
|
messageFa: 'امکان مسدود کردن حساب کاربری خودتان وجود ندارد',
|
|
},
|
|
RATE_LIMIT_EXCEEDED: {
|
|
status: HttpStatus.TOO_MANY_REQUESTS,
|
|
message: 'User rate limit exceeded',
|
|
messageFa: 'تعداد درخواستهای مجاز کاربر به پایان رسیده است',
|
|
},
|
|
SHAHKAR_NO_MATCH: {
|
|
status: HttpStatus.OK,
|
|
message: 'No match found between the provided national code and mobile number',
|
|
messageFa: 'تطبیقی بین کد ملی و شماره موبایل وارد شده یافت نشد',
|
|
},
|
|
CAR_POLICY_NOT_FOUND: {
|
|
status: HttpStatus.OK,
|
|
message: 'No policy record found',
|
|
messageFa: 'سوابق بیمهنامه خودرو یافت نشد',
|
|
},
|
|
REAL_ESTATE_NOT_FOUND: {
|
|
status: HttpStatus.OK,
|
|
message: 'No property ownership found for the provided national code and postal code',
|
|
messageFa: 'ملکی برای کد ملی و کد پستی وارد شده یافت نشد',
|
|
},
|
|
REQUEST_FAILED: {
|
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
message: 'Request failed',
|
|
messageFa: 'درخواست ناموفق بود',
|
|
},
|
|
} as const satisfies Record<string, ErrorCatalogEntry>;
|
|
|
|
export type AppErrorCode = keyof typeof ERROR_CATALOG;
|
|
|
|
export function isAppErrorCode(code: string): code is AppErrorCode {
|
|
return code in ERROR_CATALOG;
|
|
}
|
|
|
|
export function getErrorStatus(code: AppErrorCode): HttpStatus {
|
|
return ERROR_CATALOG[code].status;
|
|
}
|
|
|
|
export function buildNormalizedError(
|
|
code: AppErrorCode,
|
|
overrides: Partial<Omit<NormalizedErrorDto, 'code'>> = {},
|
|
): NormalizedErrorDto {
|
|
const entry = ERROR_CATALOG[code];
|
|
return {
|
|
code,
|
|
message: overrides.message ?? entry.message,
|
|
messageFa: overrides.messageFa ?? entry.messageFa,
|
|
providerMessage: overrides.providerMessage,
|
|
providerCode: overrides.providerCode,
|
|
providerTrackingCode: overrides.providerTrackingCode,
|
|
details: overrides.details,
|
|
conflict: overrides.conflict,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a Persian error message for a given code.
|
|
*
|
|
* @param code The NormalizedErrorDto.code value
|
|
* @param params Optional interpolation params (e.g. { provider, inquiryType })
|
|
* @returns The Persian message or undefined when no translation exists
|
|
*/
|
|
export function resolveErrorMessageFa(
|
|
code: string,
|
|
params?: Record<string, string>,
|
|
): string | undefined {
|
|
const entry = ERROR_CATALOG[code as AppErrorCode];
|
|
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
|
|
void params;
|
|
|
|
return entry.messageFa;
|
|
}
|
|
|
|
/**
|
|
* Map well-known provider error messages / codes that originate
|
|
* from upstream providers (returned as providerMessage) to their
|
|
* Persian equivalents when the provider itself does not return a
|
|
* Persian message.
|
|
*/
|
|
export const PROVIDER_MESSAGE_TRANSLATIONS: Record<string, string> = {
|
|
'No match found between the provided national code and mobile number':
|
|
'تطبیقی بین کد ملی و شماره موبایل وارد شده یافت نشد',
|
|
'Shahkar inquiry failed': 'خطا در استعلام شاهکار',
|
|
'No policy record found': 'سوابق بیمهنامه خودرو یافت نشد',
|
|
'Record not found': 'رکوردی یافت نشد',
|
|
'Request failed': 'درخواست ناموفق بود',
|
|
'No property ownership found for the provided national code and postal code':
|
|
'ملکی برای کد ملی و کد پستی وارد شده یافت نشد',
|
|
'Inquiry returned no matching result': 'نتیجهای مطابق با اطلاعات وارد شده یافت نشد',
|
|
'Insufficient role permissions': 'سطح دسترسی کافی نیست',
|
|
'Invalid credentials': 'نام کاربری یا رمز عبور صحیح نیست',
|
|
'Account is inactive': 'حساب کاربری غیرفعال است',
|
|
'Account is blocked': 'حساب کاربری مسدود شده است',
|
|
'Invalid refresh token': 'توکن نوسازی نامعتبر است',
|
|
};
|
|
|
|
export const PROVIDER_PUBLIC_MESSAGE_TRANSLATIONS: Record<
|
|
string,
|
|
{ message: string; messageFa: string }
|
|
> = {
|
|
'کد ملی اشتباه است': {
|
|
message: 'Invalid national code',
|
|
messageFa: 'کد ملی اشتباه است',
|
|
},
|
|
'کدملی اشتباه است': {
|
|
message: 'Invalid national code',
|
|
messageFa: 'کد ملی اشتباه است',
|
|
},
|
|
};
|
|
|
|
export const MESSAGE_CODE_MAP: Record<string, AppErrorCode> = {
|
|
'Invalid credentials': 'INVALID_CREDENTIALS',
|
|
'Account is inactive': 'ACCOUNT_INACTIVE',
|
|
'Account is blocked': 'ACCOUNT_BLOCKED',
|
|
'Invalid refresh token': 'INVALID_REFRESH_TOKEN',
|
|
'API key authentication is not configured': 'API_KEY_NOT_CONFIGURED',
|
|
'Invalid or missing API key': 'INVALID_API_KEY',
|
|
Unauthorized: 'UNAUTHORIZED',
|
|
'Authentication required': 'AUTHENTICATION_REQUIRED',
|
|
'Insufficient role permissions': 'INSUFFICIENT_ROLE',
|
|
'Insufficient permissions to manage users': 'INSUFFICIENT_USER_MANAGEMENT_PERMISSION',
|
|
'Only SUPER_ADMIN can assign SUPER_ADMIN role': 'SUPER_ADMIN_ROLE_REQUIRED',
|
|
'User not found': 'USER_NOT_FOUND',
|
|
'Username or email already exists': 'USERNAME_OR_EMAIL_EXISTS',
|
|
'Email already in use': 'EMAIL_ALREADY_IN_USE',
|
|
'Password must be at least 8 characters': 'PASSWORD_TOO_SHORT',
|
|
'Cannot block your own account': 'CANNOT_BLOCK_SELF',
|
|
'User rate limit exceeded': 'RATE_LIMIT_EXCEEDED',
|
|
};
|
|
|
|
export function resolveErrorCodeFromMessage(message: string): AppErrorCode | undefined {
|
|
return MESSAGE_CODE_MAP[message.trim()];
|
|
}
|
|
|
|
/**
|
|
* Try to translate an upstream provider message to Persian.
|
|
*
|
|
* @param providerMessage The English message returned by the provider
|
|
* @returns The Persian equivalent or undefined when no mapping exists
|
|
*/
|
|
export function translateProviderMessage(providerMessage: string): string | undefined {
|
|
return PROVIDER_MESSAGE_TRANSLATIONS[providerMessage.trim()];
|
|
}
|
|
|
|
export function resolveProviderPublicMessage(
|
|
providerMessage: string | undefined,
|
|
fallbackMessage = 'Provider request failed',
|
|
): { message: string; messageFa: string } {
|
|
const trimmed = providerMessage?.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
message: fallbackMessage,
|
|
messageFa: ERROR_CATALOG.PROVIDER_ERROR.messageFa,
|
|
};
|
|
}
|
|
|
|
const direct = PROVIDER_PUBLIC_MESSAGE_TRANSLATIONS[trimmed];
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
|
|
const messageFa = translateProviderMessage(trimmed);
|
|
if (messageFa) {
|
|
return {
|
|
message: trimmed,
|
|
messageFa,
|
|
};
|
|
}
|
|
|
|
if (/[آ-ی]/.test(trimmed)) {
|
|
return {
|
|
message: fallbackMessage,
|
|
messageFa: trimmed,
|
|
};
|
|
}
|
|
|
|
return {
|
|
message: trimmed,
|
|
messageFa: ERROR_CATALOG.PROVIDER_ERROR.messageFa,
|
|
};
|
|
}
|