diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..4b69031 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,36 @@ +module.exports = { + projects: [ + { + displayName: 'unit', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/src/**/*.spec.ts', '/test/unit/**/*.spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + { + displayName: 'integration', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/test/integration/**/*.e2e-spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + { + displayName: 'admin', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/test/admin/**/*.e2e-spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + { + displayName: 'smoke', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/test/smoke/**/*.smoke-spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + ], +}; diff --git a/nest-cli.json b/nest-cli.json index 03693c8..ea1a21d 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,7 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true, + "deleteOutDir": false, "assets": [{ "include": "public/**/*", "outDir": "dist" }] } } diff --git a/package-lock.json b/package-lock.json index 957c966..63e0167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,12 +53,14 @@ "@types/jest": "^29.5.14", "@types/node": "^22.19.19", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "eslint": "^9.17.0", "jest": "^29.7.0", "source-map-support": "^0.5.21", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", @@ -2372,6 +2374,19 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -3816,6 +3831,16 @@ "node": "^18.19.0 || >=20.6.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4066,6 +4091,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz", @@ -4201,6 +4233,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz", @@ -4328,6 +4367,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz", @@ -5061,6 +5124,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz", @@ -5850,6 +5920,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/concat-map/-/concat-map-0.0.1.tgz", @@ -5925,6 +6005,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6143,6 +6230,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/diff/-/diff-4.0.4.tgz", @@ -7236,6 +7334,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/forwarded/-/forwarded-0.2.0.tgz", @@ -11190,6 +11306,55 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 847dd1c..3858626 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,14 @@ "start:dev": "nest start --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", + "test": "jest --selectProjects unit integration admin", + "test:unit": "jest --selectProjects unit", + "test:integration": "jest --selectProjects integration", + "test:admin": "jest --selectProjects admin", + "test:watch": "jest --selectProjects unit integration admin --watch", + "test:cov": "jest --selectProjects unit integration admin --coverage", + "smoke": "ESG_SMOKE_ENABLED=true jest --selectProjects smoke --runInBand", + "smoke:client": "npm run smoke --", "cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts" }, "dependencies": { @@ -60,33 +65,18 @@ "@types/jest": "^29.5.14", "@types/node": "^22.19.19", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "eslint": "^9.17.0", "jest": "^29.7.0", "source-map-support": "^0.5.21", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/src/app.module.ts b/src/app.module.ts index d1acb27..247c375 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,7 +11,6 @@ import { RateLimitModule } from './rate-limit/rate-limit.module'; import { LoggingModule } from './logging/logging.module'; import { ProvidersModule } from './providers/providers.module'; import { InquiryModule } from './inquiry/inquiry.module'; -import { ClaimsModule } from './claims/claims.module'; import { DatabaseSeederModule } from './database/database-seeder.module'; /** @@ -35,7 +34,6 @@ import { DatabaseSeederModule } from './database/database-seeder.module'; DatabaseSeederModule, ProvidersModule, InquiryModule, - ClaimsModule, ], }) export class AppModule implements NestModule { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 311d88f..4833c30 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,4 @@ -import { - ForbiddenException, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AuditEvent } from '../common/enums/audit-event.enum'; import { AuditService } from '../audit/audit.service'; import { UsersService, RequestContext } from '../users/users.service'; @@ -14,6 +10,7 @@ import { ProfileDto } from './dto/profile.dto'; import { UserMapper } from '../users/mappers/user.mapper'; import { JwtPayload } from './interfaces/jwt-payload.interface'; import { AuthenticatedUser } from './interfaces/authenticated-user.interface'; +import { AppException } from '../common/exceptions/app-exception'; @Injectable() export class AuthService { @@ -34,23 +31,23 @@ export class AuthService { userAgent: context.userAgent, metadata: { reason: 'invalid_credentials' }, }); - throw new UnauthorizedException('Invalid credentials'); + throw new AppException('INVALID_CREDENTIALS'); } if (!user.isActive) { await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context); - throw new ForbiddenException('Account is inactive'); + throw new AppException('ACCOUNT_INACTIVE'); } if (user.isBlocked) { await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context); - throw new ForbiddenException('Account is blocked'); + throw new AppException('ACCOUNT_BLOCKED'); } const valid = await this.passwordService.compare(dto.password, user.password); if (!valid) { await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context); - throw new UnauthorizedException('Invalid credentials'); + throw new AppException('INVALID_CREDENTIALS'); } const tokens = await this.issueTokens(user); @@ -72,20 +69,20 @@ export class AuthService { try { payload = this.tokenService.verifyRefreshToken(refreshToken); } catch { - throw new UnauthorizedException('Invalid refresh token'); + throw new AppException('INVALID_REFRESH_TOKEN'); } const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken); if (!valid) { - throw new UnauthorizedException('Invalid refresh token'); + throw new AppException('INVALID_REFRESH_TOKEN'); } const user = await this.usersService.findById(payload.sub); if (!user.isActive) { - throw new ForbiddenException('Account is inactive'); + throw new AppException('ACCOUNT_INACTIVE'); } if (user.isBlocked) { - throw new ForbiddenException('Account is blocked'); + throw new AppException('ACCOUNT_BLOCKED'); } const tokens = await this.issueTokens(user); diff --git a/src/auth/guards/api-key.guard.ts b/src/auth/guards/api-key.guard.ts index 97160a3..f0942e0 100644 --- a/src/auth/guards/api-key.guard.ts +++ b/src/auth/guards/api-key.guard.ts @@ -2,11 +2,11 @@ import { CanActivate, ExecutionContext, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; import { API_KEY_HEADER } from '../../common/constants/app.constants'; +import { AppException } from '../../common/exceptions/app-exception'; /** * API key authentication for inquiry endpoints. @@ -22,11 +22,11 @@ export class ApiKeyGuard implements CanActivate { const expected = this.configService.get('auth.apiKey'); if (!expected) { - throw new UnauthorizedException('API key authentication is not configured'); + throw new AppException('API_KEY_NOT_CONFIGURED'); } if (!apiKey || apiKey !== expected) { - throw new UnauthorizedException('Invalid or missing API key'); + throw new AppException('INVALID_API_KEY'); } return true; diff --git a/src/auth/guards/inquiry-access.guard.ts b/src/auth/guards/inquiry-access.guard.ts index 69315f1..cfb9008 100644 --- a/src/auth/guards/inquiry-access.guard.ts +++ b/src/auth/guards/inquiry-access.guard.ts @@ -1,10 +1,11 @@ -import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { ADMIN_ROLES } from '../../common/enums/role.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Validates that the user may call a specific inquiry endpoint. @@ -28,7 +29,7 @@ export class InquiryAccessGuard implements CanActivate { const user = request.user; if (!user) { - throw new ForbiddenException('Authentication required'); + throw new AppException('AUTHENTICATION_REQUIRED'); } if (ADMIN_ROLES.includes(user.role)) { diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index c218331..6f430e8 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -1,6 +1,7 @@ -import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Protects routes with Passport JWT access strategy. @@ -14,7 +15,7 @@ export class JwtAuthGuard extends AuthGuard(JWT_ACCESS_STRATEGY) { _context: ExecutionContext, ): TUser { if (err || !user) { - throw err ?? new UnauthorizedException('Unauthorized'); + throw err ?? new AppException('UNAUTHORIZED'); } return user; } diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index a00a8f2..e3937fa 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -1,9 +1,10 @@ -import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { ROLES_KEY } from '../constants/auth.constants'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { Role } from '../../common/enums/role.enum'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Enforces @Roles() metadata against the authenticated user's role. @@ -26,7 +27,7 @@ export class RolesGuard implements CanActivate { const user = request.user; if (!user || !requiredRoles.includes(user.role)) { - throw new ForbiddenException('Insufficient role permissions'); + throw new AppException('INSUFFICIENT_ROLE'); } return true; diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 2a1587a..6117453 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; @@ -6,6 +6,7 @@ import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { UsersService } from '../../users/users.service'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Validates access JWT and attaches a slim user object to the request. @@ -27,10 +28,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_ACCESS_STRATEGY) const user = await this.usersService.findById(payload.sub); if (!user.isActive) { - throw new UnauthorizedException('Account is inactive'); + throw new AppException('ACCOUNT_INACTIVE'); } if (user.isBlocked) { - throw new UnauthorizedException('Account is blocked'); + throw new AppException('ACCOUNT_BLOCKED'); } return { diff --git a/src/claims/claims.controller.ts b/src/claims/claims.controller.ts deleted file mode 100644 index 8426c13..0000000 --- a/src/claims/claims.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Body, Controller, Post, UseGuards, UseInterceptors } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { ResponseTransformInterceptor } from '../common/interceptors/response-transform.interceptor'; -import { UserRateLimitGuard } from '../rate-limit/user-rate-limit.guard'; -import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto'; -import { ThirdPartyClaimService, ThirdPartyClaimSubmitResult } from './third-party-claim.service'; - -@ApiTags('Claims') -@ApiBearerAuth() -@Controller('claims') -@UseGuards(JwtAuthGuard, UserRateLimitGuard) -@UseInterceptors(ResponseTransformInterceptor) -export class ClaimsController { - constructor(private readonly thirdPartyClaimService: ThirdPartyClaimService) {} - - @Post('third-party/submit') - @Throttle({ default: { limit: 20, ttl: 60000 } }) - @ApiOperation({ - summary: 'Submit a third-party car financial claim to Fanavaran', - description: - 'Authenticates with Fanavaran, resolves the latest third-party policy for the national code, ' + - 'resolves ClaimExpertId from evaluation history when provided, and submits the claim.', - }) - @ApiResponse({ status: 201, description: 'Claim submitted or structured failure returned' }) - async submitThirdPartyClaim( - @Body() dto: SubmitThirdPartyClaimDto, - ): Promise { - return this.thirdPartyClaimService.submit(dto); - } -} diff --git a/src/claims/claims.module.ts b/src/claims/claims.module.ts deleted file mode 100644 index 7b395fa..0000000 --- a/src/claims/claims.module.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MongooseModule } from '@nestjs/mongoose'; -import { AuthModule } from '../auth/auth.module'; -import { ProvidersModule } from '../providers/providers.module'; -import { RateLimitModule } from '../rate-limit/rate-limit.module'; -import { ClaimsController } from './claims.controller'; -import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service'; -import { FanavaranApiService } from './fanavaran/fanavaran-api.service'; -import { EvaluationRequest, EvaluationRequestSchema } from './schemas/evaluation-request.schema'; -import { FieldExpert, FieldExpertSchema } from './schemas/field-expert.schema'; -import { ClaimExpertResolverService } from './services/claim-expert-resolver.service'; -import { EvaluationRequestService } from './services/evaluation-request.service'; -import { ThirdPartyClaimService } from './third-party-claim.service'; - -@Module({ - imports: [ - AuthModule, - ProvidersModule, - RateLimitModule, - MongooseModule.forFeature([ - { name: FieldExpert.name, schema: FieldExpertSchema }, - { name: EvaluationRequest.name, schema: EvaluationRequestSchema }, - ]), - ], - controllers: [ClaimsController], - providers: [ - FanavaranAuthService, - FanavaranApiService, - EvaluationRequestService, - ClaimExpertResolverService, - ThirdPartyClaimService, - ], - exports: [ThirdPartyClaimService, FanavaranApiService, ClaimExpertResolverService], -}) -export class ClaimsModule {} diff --git a/src/claims/dto/submit-third-party-claim.dto.ts b/src/claims/dto/submit-third-party-claim.dto.ts deleted file mode 100644 index a61e38c..0000000 --- a/src/claims/dto/submit-third-party-claim.dto.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { - IsEnum, - IsInt, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - Length, - Matches, - Max, - Min, - ValidateIf, -} from 'class-validator'; -import { FanavaranInsuranceLineId } from '../fanavaran/fanavaran.types'; - -export class SubmitThirdPartyClaimDto { - @ApiProperty({ example: '0059132574', description: 'Policy holder national code (10 digits)' }) - @IsString() - @IsNotEmpty() - @Length(10, 10) - @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' }) - nationalCode!: string; - - @ApiPropertyOptional({ - example: FanavaranInsuranceLineId.THIRD_PARTY, - enum: FanavaranInsuranceLineId, - default: FanavaranInsuranceLineId.THIRD_PARTY, - }) - @IsOptional() - @IsEnum(FanavaranInsuranceLineId) - @Type(() => Number) - insuranceLineId?: FanavaranInsuranceLineId; - - @ApiPropertyOptional({ - description: 'Override ClaimExpertId instead of resolving from evaluation history', - example: 4662, - }) - @IsOptional() - @IsInt() - @Type(() => Number) - claimExpertId?: number; - - @ApiPropertyOptional({ - description: 'Evaluation/claim case id — latest expert from history is used when claimExpertId is omitted', - example: 'CASE-2026-001', - }) - @IsOptional() - @IsString() - evaluationRequestId?: string; - - @ApiPropertyOptional({ - description: 'Use a specific policy id instead of resolving the latest policy from Fanavaran', - example: 14821240, - }) - @IsOptional() - @IsInt() - @Type(() => Number) - policyId?: number; - - @ApiProperty({ example: 701 }) - @IsInt() - @Type(() => Number) - AccidentCityId!: number; - - @ApiProperty({ example: 155 }) - @IsInt() - @Type(() => Number) - AccidentReportTypeId!: number; - - @ApiProperty({ example: 1 }) - @IsInt() - @Type(() => Number) - AccidentVehicleUsedId!: number; - - @ApiProperty({ example: 167 }) - @IsInt() - @Type(() => Number) - CompensationReferenceId!: number; - - @ApiProperty({ example: 2 }) - @IsInt() - @Type(() => Number) - CulpritLicenceTypeId!: number; - - @ApiProperty({ example: 337 }) - @IsInt() - @Type(() => Number) - CulpritTypeId!: number; - - @ApiProperty({ example: 6 }) - @IsInt() - @Type(() => Number) - AccidentCauseId!: number; - - @ApiProperty({ example: '1405/03/17' }) - @IsString() - @IsNotEmpty() - AccidentDate!: string; - - @ApiProperty({ example: '1405/03/17' }) - @IsString() - @IsNotEmpty() - AnnouncementDate!: string; - - @ApiProperty({ example: '1405/03/17' }) - @IsString() - @IsNotEmpty() - DocReceivedDate!: string; - - @ApiProperty({ example: '15:59' }) - @IsString() - @IsNotEmpty() - AccidentTime!: string; - - @ApiProperty({ example: 'تهران، محله کوی نصر، بزرگراه جلال آل احمد' }) - @IsString() - @IsNotEmpty() - AccidentLocationAddress!: string; - - @ApiProperty({ example: 143 }) - @IsNumber() - @Min(0) - @Type(() => Number) - EstimateAmount!: number; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - AccidentCulpritId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - CouponNo?: string | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - CourtArchiveNo?: string | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - CulpritLicenceCityId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - CulpritLicenceCountryId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - CulpritLicenceForeignCityName?: string | null; - - @ApiProperty({ example: '1394/10/13' }) - @IsString() - @IsNotEmpty() - CulpritLicenceIssuDate!: string; - - @ApiProperty({ example: '1124242' }) - @IsString() - @IsNotEmpty() - CulpritLicenceNo!: string; - - @ApiProperty({ example: 1 }) - @IsInt() - @Min(1) - @Type(() => Number) - DamagedCount!: number; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - DmgAssessorFirstCreationTime?: string | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - EntryDate?: string | null; - - @ApiProperty({ example: 1 }) - @IsInt() - @Min(0) - @Max(1) - @Type(() => Number) - IsLicenseMatchWithVehicleKind!: number; - - @ApiProperty({ example: 0 }) - @IsInt() - @Min(0) - @Max(1) - @Type(() => Number) - HasOtherCulprit!: number; - - @ApiProperty({ example: 0 }) - @IsInt() - @Min(0) - @Max(1) - @Type(() => Number) - IsAccidentOutOfBorder!: number; - - @ApiProperty({ example: 0 }) - @IsInt() - @Min(0) - @Max(1) - @Type(() => Number) - IsFatalAccident!: number; - - @ApiProperty({ example: 0 }) - @IsInt() - @Min(0) - @Max(1) - @Type(() => Number) - IsPlaqueChanged!: number; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueCityId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueKindId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueLeftNo?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueMiddleCodeId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - PlaqueNo?: string | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueRightNo?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueSampleId?: number | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - PlaqueSerial?: number | null; - - @ApiProperty({ example: 1 }) - @IsInt() - @Type(() => Number) - PoliceOfficerId!: number; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - PoliceReportDesc?: string | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - PoliceReportSeri?: string | null; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - PoliceReportSerial?: string | null; - - @ApiPropertyOptional({ example: '', default: '' }) - @IsOptional() - @IsString() - PreviousPolicyEndDate?: string; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - TrackingCode?: string | null; - - @ApiProperty({ example: 0 }) - @IsInt() - @Min(0) - @Max(1) - @Type(() => Number) - IsLicenseReplacement!: number; - - @ApiPropertyOptional({ nullable: true }) - @IsOptional() - @ValidateIf((_obj, value) => value !== null) - @IsInt() - @Type(() => Number) - UnknownCulpritCauseId?: number | null; -} diff --git a/src/claims/fanavaran/fanavaran-api.service.ts b/src/claims/fanavaran/fanavaran-api.service.ts deleted file mode 100644 index add506f..0000000 --- a/src/claims/fanavaran/fanavaran-api.service.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { AxiosError, AxiosInstance } from 'axios'; -import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper'; -import { FanavaranConfig } from '../../config/configuration'; -import { FanavaranAuthService } from './fanavaran-auth.service'; -import { - FanavaranInsuranceLineId, - FanavaranPolicySummary, - ThirdPartyCarFinancialClaimPayload, -} from './fanavaran.types'; - -@Injectable() -export class FanavaranApiService { - private readonly logger = new Logger(FanavaranApiService.name); - private readonly httpClient: AxiosInstance; - private readonly config: FanavaranConfig; - - constructor( - configService: ConfigService, - private readonly authService: FanavaranAuthService, - ) { - this.config = configService.get('fanavaran')!; - this.httpClient = createOutboundAxiosInstance({ - baseURL: this.config.baseUrl, - timeout: this.config.timeout, - headers: { - 'Content-Type': 'application/json', - }, - }); - } - - async inquiryLatestPolicy( - nationalCode: string, - insuranceLineId: FanavaranInsuranceLineId = FanavaranInsuranceLineId.THIRD_PARTY, - ): Promise { - const authenticationToken = await this.authService.getAuthenticationToken(); - const response = await this.httpClient.get( - '/api/BimeApi/v2.0/common/Policies/inquiry-my-policies', - { - params: { - InsuranceLineId: insuranceLineId, - NationalCode: nationalCode, - }, - headers: this.buildCommonHeaders(authenticationToken, this.config.policyInquiryLocation), - }, - ); - - const policies = Array.isArray(response.data) ? response.data : []; - if (policies.length === 0) { - throw new Error( - `No Fanavaran policies found for nationalCode=${nationalCode}, insuranceLineId=${insuranceLineId}`, - ); - } - - const latest = this.pickLatestPolicy(policies); - this.logger.log( - `Fanavaran policy selected | nationalCode=${nationalCode} | policyId=${latest.PolicyId} | issuDate=${latest.IssuDate}`, - ); - return latest; - } - - async submitThirdPartyCarFinancialClaim( - payload: ThirdPartyCarFinancialClaimPayload, - ): Promise { - const authenticationToken = await this.authService.getAuthenticationToken(); - - try { - const response = await this.httpClient.post( - '/api/BimeApi/v2.0/car/third-party-car-financial-claims', - payload, - { - headers: this.buildCommonHeaders(authenticationToken, this.config.claimSubmitLocation), - }, - ); - return response.data; - } catch (error) { - throw this.formatApiError(error); - } - } - - pickLatestPolicy(policies: FanavaranPolicySummary[]): FanavaranPolicySummary { - return [...policies].sort((left, right) => { - const dateCompare = right.IssuDate.localeCompare(left.IssuDate); - if (dateCompare !== 0) { - return dateCompare; - } - return right.PolicyId - left.PolicyId; - })[0]; - } - - private buildCommonHeaders( - authenticationToken: string, - location: number, - ): Record { - return { - authenticationToken, - CorpId: this.config.corpId, - ContractId: this.config.contractId, - Location: location, - }; - } - - private formatApiError(error: unknown): Error { - if (!(error instanceof AxiosError)) { - return error instanceof Error ? error : new Error(String(error)); - } - - const status = error.response?.status; - const body = - typeof error.response?.data === 'string' - ? error.response.data - : JSON.stringify(error.response?.data ?? {}); - - return new Error( - `Fanavaran API request failed${status ? ` (${status})` : ''}: ${error.message} | body=${body}`, - ); - } -} diff --git a/src/claims/fanavaran/fanavaran-auth.service.ts b/src/claims/fanavaran/fanavaran-auth.service.ts deleted file mode 100644 index 4897b3c..0000000 --- a/src/claims/fanavaran/fanavaran-auth.service.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { AxiosInstance } from 'axios'; -import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper'; -import { FanavaranConfig } from '../../config/configuration'; -import { GeneralTokenService } from '../../providers/services/general-token.service'; -import { FanavaranLoginUserInfo } from './fanavaran.types'; - -const APP_TOKEN_PROVIDER = 'Fanavaran:AppToken'; -const AUTH_TOKEN_PROVIDER = 'Fanavaran:AuthToken'; - -@Injectable() -export class FanavaranAuthService { - private readonly logger = new Logger(FanavaranAuthService.name); - private readonly httpClient: AxiosInstance; - private readonly config: FanavaranConfig; - - constructor( - configService: ConfigService, - private readonly generalTokenService: GeneralTokenService, - ) { - this.config = configService.get('fanavaran')!; - this.httpClient = createOutboundAxiosInstance({ - baseURL: this.config.baseUrl, - timeout: this.config.timeout, - }); - } - - isEnabled(): boolean { - return ( - this.config.enabled && - Boolean(this.config.baseUrl) && - Boolean(this.config.appName) && - Boolean(this.config.appSecret) && - Boolean(this.config.userName) && - Boolean(this.config.password) - ); - } - - async getAuthenticationToken(): Promise { - const cached = await this.generalTokenService.getLatestToken(AUTH_TOKEN_PROVIDER); - if (cached && !(await this.generalTokenService.isTokenExpired(cached))) { - return cached.accessToken; - } - - const appToken = await this.getAppToken(); - const { authenticationToken } = await this.login(appToken); - return authenticationToken; - } - - private async getAppToken(): Promise { - const cached = await this.generalTokenService.getLatestToken(APP_TOKEN_PROVIDER); - if (cached && !(await this.generalTokenService.isTokenExpired(cached))) { - return cached.accessToken; - } - - const response = await this.httpClient.post( - '/api/EITAuthentication/GetAppToken', - undefined, - { - headers: { - appname: this.config.appName, - secret: this.config.appSecret, - }, - }, - ); - - const appToken = this.readHeader(response.headers, 'apptoken'); - if (!appToken) { - throw new Error('Fanavaran GetAppToken did not return appToken header'); - } - - await this.cacheToken(APP_TOKEN_PROVIDER, appToken, this.config.appTokenTtlSeconds); - return appToken; - } - - private async login(appToken: string): Promise<{ - authenticationToken: string; - user: FanavaranLoginUserInfo; - }> { - const response = await this.httpClient.post('/api/EITAuthentication/Login', undefined, { - headers: { - appToken, - userName: this.config.userName, - password: this.config.password, - }, - }); - - const authenticationToken = this.readHeader(response.headers, 'authenticationtoken'); - if (!authenticationToken) { - throw new Error('Fanavaran Login did not return authenticationToken header'); - } - - await this.cacheToken( - AUTH_TOKEN_PROVIDER, - authenticationToken, - this.config.authTokenTtlSeconds, - ); - - const user = response.data as FanavaranLoginUserInfo; - this.logger.log( - `Fanavaran login succeeded | user=${user?.UserName ?? this.config.userName}`, - ); - - return { authenticationToken, user }; - } - - private async cacheToken( - serviceProvider: string, - token: string, - ttlSeconds: number, - ): Promise { - const expiresAt = new Date(Date.now() + ttlSeconds * 1000); - await this.generalTokenService.create({ - serviceProvider, - tokenType: 'Bearer', - url: this.config.baseUrl, - clientId: this.config.appName, - clientSecret: '***', - username: this.config.userName, - scope: serviceProvider, - accessToken: token, - expiresIn: ttlSeconds, - expiresAt, - }); - } - - private readHeader( - headers: Record, - name: string, - ): string | undefined { - const value = headers[name]; - if (typeof value === 'string' && value.trim()) { - return value.trim(); - } - if (Array.isArray(value) && typeof value[0] === 'string') { - return value[0].trim(); - } - return undefined; - } -} diff --git a/src/claims/fanavaran/fanavaran.types.ts b/src/claims/fanavaran/fanavaran.types.ts deleted file mode 100644 index 49c1a9f..0000000 --- a/src/claims/fanavaran/fanavaran.types.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** Fanavaran (Iran EIT) insurance line identifiers. */ -export enum FanavaranInsuranceLineId { - CAR_BODY = 4, - THIRD_PARTY = 5, -} - -export interface FanavaranPolicySummary { - BeginDate: string; - CustomerId: number; - EffectiveDate: string | null; - EndDate: string; - EndoNo: number; - InsuranceLineId: number; - IsClosed: number; - IssuDate: string; - OpUnitId: number; - PayerCustomerId: number; - PersonRoleId: number; - PolicyGroupId: number; - PolicyId: number; - PolicyNo: number; - PolicyVerNo: number; - Status: number; - TranTypeId: number; -} - -export interface FanavaranLoginUserInfo { - UserName: string; - UserId: string; - FarsiName: string | null; -} - -export interface ThirdPartyCarFinancialClaimPayload { - AccidentCityId: number; - AccidentReportTypeId: number; - AccidentVehicleUsedId: number; - ClaimExpertId: number; - CompensationReferenceId: number; - CulpritLicenceTypeId: number; - CulpritTypeId: number; - AccidentCauseId: number; - AccidentDate: string; - AnnouncementDate: string; - DocReceivedDate: string; - AccidentTime: string; - AccidentLocationAddress: string; - EstimateAmount: number; - AccidentCulpritId: number | null; - CouponNo: string | null; - CourtArchiveNo: string | null; - CulpritLicenceCityId: number | null; - CulpritLicenceCountryId: number | null; - CulpritLicenceForeignCityName: string | null; - CulpritLicenceIssuDate: string; - CulpritLicenceNo: string; - DamagedCount: number; - DmgAssessorFirstCreationTime: string | null; - EntryDate: string | null; - IsLicenseMatchWithVehicleKind: number; - HasOtherCulprit: number; - IsAccidentOutOfBorder: number; - IsFatalAccident: number; - IsPlaqueChanged: number; - PlaqueCityId: number | null; - PlaqueKindId: number | null; - PlaqueLeftNo: number | null; - PlaqueMiddleCodeId: number | null; - PlaqueNo: string | null; - PlaqueRightNo: number | null; - PlaqueSampleId: number | null; - PlaqueSerial: number | null; - PoliceOfficerId: number; - PoliceReportDesc: string | null; - PoliceReportSeri: string | null; - PoliceReportSerial: string | null; - PolicyId: number; - PreviousPolicyEndDate: string; - TrackingCode: string | null; - IsLicenseReplacement: number; - UnknownCulpritCauseId: number | null; -} diff --git a/src/claims/schemas/evaluation-request.schema.ts b/src/claims/schemas/evaluation-request.schema.ts deleted file mode 100644 index e724a2c..0000000 --- a/src/claims/schemas/evaluation-request.schema.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Types } from 'mongoose'; - -export type EvaluationRequestDocument = HydratedDocument; - -@Schema({ _id: false }) -export class EvaluationHistoryEntry { - @Prop({ type: Types.ObjectId, ref: 'FieldExpert', required: true }) - expertId!: Types.ObjectId; - - @Prop() - expertCode?: number; - - @Prop({ required: true, default: () => new Date() }) - evaluatedAt!: Date; -} - -export const EvaluationHistoryEntrySchema = - SchemaFactory.createForClass(EvaluationHistoryEntry); - -@Schema({ timestamps: true, collection: 'evaluation_requests' }) -export class EvaluationRequest { - @Prop({ required: true, unique: true, trim: true }) - requestId!: string; - - @Prop({ type: [EvaluationHistoryEntrySchema], default: [] }) - evaluations!: EvaluationHistoryEntry[]; -} - -export const EvaluationRequestSchema = SchemaFactory.createForClass(EvaluationRequest); diff --git a/src/claims/schemas/field-expert.schema.ts b/src/claims/schemas/field-expert.schema.ts deleted file mode 100644 index 1346104..0000000 --- a/src/claims/schemas/field-expert.schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Types } from 'mongoose'; - -export type FieldExpertDocument = HydratedDocument; - -@Schema({ timestamps: true, collection: 'field_experts' }) -export class FieldExpert { - @Prop({ required: true, trim: true }) - name!: string; - - @Prop({ required: true, unique: true }) - expertCode!: number; - - @Prop({ default: true }) - active!: boolean; -} - -export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert); diff --git a/src/claims/services/claim-expert-resolver.service.ts b/src/claims/services/claim-expert-resolver.service.ts deleted file mode 100644 index 96a9ef9..0000000 --- a/src/claims/services/claim-expert-resolver.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { Model, Types } from 'mongoose'; -import { ConfigService } from '@nestjs/config'; -import { FanavaranConfig } from '../../config/configuration'; -import { FieldExpert, FieldExpertDocument } from '../schemas/field-expert.schema'; -import { EvaluationRequestService } from './evaluation-request.service'; - -export interface ClaimExpertResolutionInput { - claimExpertId?: number; - evaluationRequestId?: string; -} - -@Injectable() -export class ClaimExpertResolverService { - private readonly config: FanavaranConfig; - - constructor( - configService: ConfigService, - @InjectModel(FieldExpert.name) - private readonly fieldExpertModel: Model, - private readonly evaluationRequestService: EvaluationRequestService, - ) { - this.config = configService.get('fanavaran')!; - } - - async resolveClaimExpertId(input: ClaimExpertResolutionInput): Promise { - if (input.claimExpertId !== undefined && input.claimExpertId !== null) { - return input.claimExpertId; - } - - if (input.evaluationRequestId) { - const resolved = await this.resolveFromEvaluationHistory(input.evaluationRequestId); - if (resolved !== undefined) { - return resolved; - } - } - - return this.config.defaultClaimExpertId; - } - - private async resolveFromEvaluationHistory( - evaluationRequestId: string, - ): Promise { - const evaluationRequest = - await this.evaluationRequestService.findByRequestId(evaluationRequestId); - if (!evaluationRequest?.evaluations?.length) { - return undefined; - } - - const latestEvaluation = [...evaluationRequest.evaluations].sort( - (left, right) => right.evaluatedAt.getTime() - left.evaluatedAt.getTime(), - )[0]; - - if (latestEvaluation.expertCode !== undefined && latestEvaluation.expertCode !== null) { - return latestEvaluation.expertCode; - } - - if (!latestEvaluation.expertId) { - return undefined; - } - - const expert = await this.fieldExpertModel - .findOne({ - _id: new Types.ObjectId(latestEvaluation.expertId), - active: true, - }) - .exec(); - - return expert?.expertCode; - } -} diff --git a/src/claims/services/evaluation-request.service.ts b/src/claims/services/evaluation-request.service.ts deleted file mode 100644 index 45bde64..0000000 --- a/src/claims/services/evaluation-request.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; -import { - EvaluationRequest, - EvaluationRequestDocument, -} from '../schemas/evaluation-request.schema'; - -@Injectable() -export class EvaluationRequestService { - constructor( - @InjectModel(EvaluationRequest.name) - private readonly evaluationRequestModel: Model, - ) {} - - async findByRequestId(requestId: string): Promise { - return this.evaluationRequestModel.findOne({ requestId }).exec(); - } -} diff --git a/src/claims/third-party-claim.service.ts b/src/claims/third-party-claim.service.ts deleted file mode 100644 index b1df139..0000000 --- a/src/claims/third-party-claim.service.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; -import { generateTrackingCode } from '../common/helpers/tracking-code.helper'; -import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto'; -import { FanavaranApiService } from './fanavaran/fanavaran-api.service'; -import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service'; -import { - FanavaranInsuranceLineId, - FanavaranPolicySummary, - ThirdPartyCarFinancialClaimPayload, -} from './fanavaran/fanavaran.types'; -import { ClaimExpertResolverService } from './services/claim-expert-resolver.service'; - -export interface ThirdPartyClaimSubmitResult { - success: boolean; - trackingCode: string; - message: string; - duration: number; - data: { - policyId: number; - claimExpertId: number; - policy: FanavaranPolicySummary; - fanavaranResponse: unknown; - } | null; - error: { code: string; message: string } | null; -} - -@Injectable() -export class ThirdPartyClaimService { - private readonly logger = new Logger(ThirdPartyClaimService.name); - - constructor( - private readonly fanavaranAuthService: FanavaranAuthService, - private readonly fanavaranApiService: FanavaranApiService, - private readonly claimExpertResolver: ClaimExpertResolverService, - ) {} - - async submit(dto: SubmitThirdPartyClaimDto): Promise { - const trackingCode = generateTrackingCode(); - const start = Date.now(); - - if (!this.fanavaranAuthService.isEnabled()) { - throw new ServiceUnavailableException('Fanavaran integration is not configured'); - } - - try { - const insuranceLineId = - dto.insuranceLineId ?? FanavaranInsuranceLineId.THIRD_PARTY; - const claimExpertId = await this.claimExpertResolver.resolveClaimExpertId({ - claimExpertId: dto.claimExpertId, - evaluationRequestId: dto.evaluationRequestId, - }); - - const policy = - dto.policyId !== undefined - ? ({ PolicyId: dto.policyId } as FanavaranPolicySummary) - : await this.fanavaranApiService.inquiryLatestPolicy( - dto.nationalCode, - insuranceLineId, - ); - - const payload = this.buildClaimPayload(dto, policy.PolicyId, claimExpertId); - const fanavaranResponse = - await this.fanavaranApiService.submitThirdPartyCarFinancialClaim(payload); - - const duration = Date.now() - start; - this.logger.log( - `Third-party claim submitted | trackingCode=${trackingCode} | policyId=${policy.PolicyId} | claimExpertId=${claimExpertId} | durationMs=${duration}`, - ); - - return { - success: true, - trackingCode, - message: 'Third-party claim submitted successfully', - duration, - data: { - policyId: policy.PolicyId, - claimExpertId, - policy, - fanavaranResponse, - }, - error: null, - }; - } catch (error) { - const duration = Date.now() - start; - const message = error instanceof Error ? error.message : 'Third-party claim submission failed'; - this.logger.error( - `Third-party claim failed | trackingCode=${trackingCode} | durationMs=${duration} | ${message}`, - ); - - return { - success: false, - trackingCode, - message, - duration, - data: null, - error: { - code: 'THIRD_PARTY_CLAIM_FAILED', - message, - }, - }; - } - } - - private buildClaimPayload( - dto: SubmitThirdPartyClaimDto, - policyId: number, - claimExpertId: number, - ): ThirdPartyCarFinancialClaimPayload { - return { - AccidentCityId: dto.AccidentCityId, - AccidentReportTypeId: dto.AccidentReportTypeId, - AccidentVehicleUsedId: dto.AccidentVehicleUsedId, - ClaimExpertId: claimExpertId, - CompensationReferenceId: dto.CompensationReferenceId, - CulpritLicenceTypeId: dto.CulpritLicenceTypeId, - CulpritTypeId: dto.CulpritTypeId, - AccidentCauseId: dto.AccidentCauseId, - AccidentDate: dto.AccidentDate, - AnnouncementDate: dto.AnnouncementDate, - DocReceivedDate: dto.DocReceivedDate, - AccidentTime: dto.AccidentTime, - AccidentLocationAddress: dto.AccidentLocationAddress, - EstimateAmount: dto.EstimateAmount, - AccidentCulpritId: dto.AccidentCulpritId ?? null, - CouponNo: dto.CouponNo ?? null, - CourtArchiveNo: dto.CourtArchiveNo ?? null, - CulpritLicenceCityId: dto.CulpritLicenceCityId ?? null, - CulpritLicenceCountryId: dto.CulpritLicenceCountryId ?? null, - CulpritLicenceForeignCityName: dto.CulpritLicenceForeignCityName ?? null, - CulpritLicenceIssuDate: dto.CulpritLicenceIssuDate, - CulpritLicenceNo: dto.CulpritLicenceNo, - DamagedCount: dto.DamagedCount, - DmgAssessorFirstCreationTime: dto.DmgAssessorFirstCreationTime ?? null, - EntryDate: dto.EntryDate ?? null, - IsLicenseMatchWithVehicleKind: dto.IsLicenseMatchWithVehicleKind, - HasOtherCulprit: dto.HasOtherCulprit, - IsAccidentOutOfBorder: dto.IsAccidentOutOfBorder, - IsFatalAccident: dto.IsFatalAccident, - IsPlaqueChanged: dto.IsPlaqueChanged, - PlaqueCityId: dto.PlaqueCityId ?? null, - PlaqueKindId: dto.PlaqueKindId ?? null, - PlaqueLeftNo: dto.PlaqueLeftNo ?? null, - PlaqueMiddleCodeId: dto.PlaqueMiddleCodeId ?? null, - PlaqueNo: dto.PlaqueNo ?? null, - PlaqueRightNo: dto.PlaqueRightNo ?? null, - PlaqueSampleId: dto.PlaqueSampleId ?? null, - PlaqueSerial: dto.PlaqueSerial ?? null, - PoliceOfficerId: dto.PoliceOfficerId, - PoliceReportDesc: dto.PoliceReportDesc ?? null, - PoliceReportSeri: dto.PoliceReportSeri ?? null, - PoliceReportSerial: dto.PoliceReportSerial ?? null, - PolicyId: policyId, - PreviousPolicyEndDate: dto.PreviousPolicyEndDate ?? '', - TrackingCode: dto.TrackingCode ?? null, - IsLicenseReplacement: dto.IsLicenseReplacement, - UnknownCulpritCauseId: dto.UnknownCulpritCauseId ?? null, - }; - } -} diff --git a/src/cli/create-super-admin.service.ts b/src/cli/create-super-admin.service.ts index 9adc261..4666393 100644 --- a/src/cli/create-super-admin.service.ts +++ b/src/cli/create-super-admin.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { randomBytes } from 'crypto'; @@ -8,6 +8,7 @@ import { PasswordService } from '../auth/services/password.service'; import { User, UserDocument } from '../users/schemas/user.schema'; import { UserMapper } from '../users/mappers/user.mapper'; import { UserResponseDto } from '../users/dto/user-response.dto'; +import { AppException } from '../common/exceptions/app-exception'; export interface CreateSuperAdminInput { fullName: string; @@ -27,7 +28,7 @@ export class CreateSuperAdminService { async create(input: CreateSuperAdminInput): Promise { if (input.password.length < 8) { - throw new ConflictException('Password must be at least 8 characters'); + throw new AppException('PASSWORD_TOO_SHORT'); } const username = input.username.toLowerCase().trim(); @@ -35,7 +36,7 @@ export class CreateSuperAdminService { const existing = await this.userModel.findOne({ $or: [{ username }, { email }] }); if (existing) { - throw new ConflictException('Username or email already exists'); + throw new AppException('USERNAME_OR_EMAIL_EXISTS'); } const superAdminExists = await this.userModel.exists({ role: Role.SUPER_ADMIN }); diff --git a/src/common/constants/error-messages.ts b/src/common/constants/error-messages.ts new file mode 100644 index 0000000..496e830 --- /dev/null +++ b/src/common/constants/error-messages.ts @@ -0,0 +1,374 @@ +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; + +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> = {}, +): 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 | 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 = { + '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 = { + '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, + }; +} diff --git a/src/common/dto/base-inquiry-response.dto.ts b/src/common/dto/base-inquiry-response.dto.ts index 3684e2f..cf6ed11 100644 --- a/src/common/dto/base-inquiry-response.dto.ts +++ b/src/common/dto/base-inquiry-response.dto.ts @@ -18,6 +18,9 @@ export class BaseInquiryResponseDto> { @ApiPropertyOptional({ example: 'Inquiry completed successfully' }) message?: string; + @ApiPropertyOptional({ example: 'استعلام با موفقیت انجام شد' }) + messageFa?: string; + @ApiPropertyOptional({ nullable: true }) data?: T | null; diff --git a/src/common/dto/normalized-error.dto.ts b/src/common/dto/normalized-error.dto.ts index 2b2b9b9..b708340 100644 --- a/src/common/dto/normalized-error.dto.ts +++ b/src/common/dto/normalized-error.dto.ts @@ -11,6 +11,9 @@ export class NormalizedErrorDto { @ApiProperty({ example: 'Provider request timed out' }) message!: string; + @ApiPropertyOptional({ example: 'درخواست به سرویسدهنده زمانبر شد' }) + messageFa?: string; + @ApiPropertyOptional({ example: 'سرویس در دسترس نیست' }) providerMessage?: string; diff --git a/src/common/exceptions/app-exception.spec.ts b/src/common/exceptions/app-exception.spec.ts new file mode 100644 index 0000000..1926c76 --- /dev/null +++ b/src/common/exceptions/app-exception.spec.ts @@ -0,0 +1,94 @@ +import { HttpStatus } from '@nestjs/common'; +import { AppException } from './app-exception'; +import { + buildNormalizedError, + resolveProviderPublicMessage, + resolveErrorCodeFromMessage, +} from '../constants/error-messages'; +import { translateError } from '../helpers/translate-error.helper'; + +describe('application error contract', () => { + it('builds normalized errors from stable catalog codes', () => { + const error = buildNormalizedError('INVALID_CREDENTIALS'); + + expect(error).toEqual({ + code: 'INVALID_CREDENTIALS', + message: 'Invalid credentials', + messageFa: 'نام کاربری یا رمز عبور صحیح نیست', + providerMessage: undefined, + providerCode: undefined, + providerTrackingCode: undefined, + details: undefined, + conflict: undefined, + }); + }); + + it('serializes AppException with the catalog status and normalized error', () => { + const exception = new AppException('RATE_LIMIT_EXCEEDED'); + + expect(exception.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS); + expect(exception.normalizedError.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(exception.normalizedError.messageFa).toBe( + 'تعداد درخواست‌های مجاز کاربر به پایان رسیده است', + ); + }); + + it('bridges known legacy English messages to catalog codes', () => { + expect(resolveErrorCodeFromMessage('Invalid credentials')).toBe('INVALID_CREDENTIALS'); + expect( + translateError({ + code: 'HTTP_ERROR', + message: 'Invalid credentials', + }), + ).toMatchObject({ + code: 'INVALID_CREDENTIALS', + messageFa: 'نام کاربری یا رمز عبور صحیح نیست', + }); + }); + + it('keeps raw provider diagnostics separate from public provider messages', () => { + expect( + translateError({ + code: 'PROVIDER_ERROR', + message: 'Invalid national code', + messageFa: 'کد ملی اشتباه است', + providerMessage: 'کد ملی اشتباه است', + providerCode: 'PROVIDER_ERROR', + providerTrackingCode: 'provider-tracking', + }), + ).toEqual({ + code: 'PROVIDER_ERROR', + message: 'Invalid national code', + messageFa: 'کد ملی اشتباه است', + providerMessage: 'کد ملی اشتباه است', + providerCode: 'PROVIDER_ERROR', + providerTrackingCode: 'provider-tracking', + details: undefined, + conflict: undefined, + }); + }); + + it('maps known Persian provider text to stable public English and Persian messages', () => { + expect(resolveProviderPublicMessage('کد ملی اشتباه است')).toEqual({ + message: 'Invalid national code', + messageFa: 'کد ملی اشتباه است', + }); + }); + + it('uses the generic public message for unknown provider errors', () => { + expect( + translateError({ + code: 'UNKNOWN_ERROR', + message: 'Unknown provider error', + providerMessage: 'No policy record found', + providerCode: 'POLICY_NOT_FOUND', + }), + ).toMatchObject({ + code: 'UNKNOWN_ERROR', + message: 'Unknown provider error', + messageFa: 'خطای ناشناخته در سرویس‌دهنده', + providerMessage: 'No policy record found', + providerCode: 'POLICY_NOT_FOUND', + }); + }); +}); diff --git a/src/common/exceptions/app-exception.ts b/src/common/exceptions/app-exception.ts new file mode 100644 index 0000000..2f5f759 --- /dev/null +++ b/src/common/exceptions/app-exception.ts @@ -0,0 +1,34 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { + AppErrorCode, + buildNormalizedError, + getErrorStatus, +} from '../constants/error-messages'; + +export interface AppExceptionOptions { + message?: string; + messageFa?: string; + providerMessage?: string; + providerCode?: string; + providerTrackingCode?: string; + details?: NormalizedErrorDto['details']; + conflict?: NormalizedErrorDto['conflict']; + status?: HttpStatus; +} + +export class AppException extends HttpException { + public readonly normalizedError: NormalizedErrorDto; + + constructor(code: AppErrorCode, options: AppExceptionOptions = {}) { + const normalizedError = buildNormalizedError(code, options); + super( + { + message: normalizedError.message, + error: normalizedError, + }, + options.status ?? getErrorStatus(code), + ); + this.normalizedError = normalizedError; + } +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index c87a744..37a1e09 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -11,6 +11,12 @@ import { NormalizedErrorDto } from '../dto/normalized-error.dto'; import { buildInquiryResponse } from '../helpers/inquiry-response.helper'; import { ProviderException } from '../exceptions/provider.exception'; import { isNormalizedError } from '../helpers/validation-error.helper'; +import { translateError } from '../helpers/translate-error.helper'; +import { AppException } from '../exceptions/app-exception'; +import { + buildNormalizedError, + resolveErrorCodeFromMessage, +} from '../constants/error-messages'; /** * Global exception filter — normalizes all errors into BaseInquiryResponseDto @@ -52,20 +58,24 @@ export class AllExceptionsFilter implements ExceptionFilter { } private extractNormalizedError(exception: unknown): NormalizedErrorDto { - if (exception instanceof ProviderException) { + if (exception instanceof AppException) { return exception.normalizedError; } + if (exception instanceof ProviderException) { + return translateError(exception.normalizedError); + } + if (exception instanceof HttpException) { const res = exception.getResponse(); if (typeof res === 'object' && res !== null) { if ('error' in res && isNormalizedError((res as { error: unknown }).error)) { - return (res as { error: NormalizedErrorDto }).error; + return translateError((res as { error: NormalizedErrorDto }).error); } if (isNormalizedError(res)) { - return res; + return translateError(res); } } @@ -74,15 +84,22 @@ export class AllExceptionsFilter implements ExceptionFilter { ? res : (res as { message?: string | string[] }).message; - return { - code: 'HTTP_ERROR', - message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message), - }; + const normalizedMessage = Array.isArray(message) + ? message.join(', ') + : String(message ?? exception.message); + const code = resolveErrorCodeFromMessage(normalizedMessage); + + return code + ? buildNormalizedError(code, { message: normalizedMessage }) + : translateError({ + code: 'HTTP_ERROR', + message: normalizedMessage, + }); } - return { + return translateError({ code: 'INTERNAL_ERROR', message: exception instanceof Error ? exception.message : 'Unexpected error', - }; + }); } } diff --git a/src/common/helpers/inquiry-response.helper.ts b/src/common/helpers/inquiry-response.helper.ts index 41db395..af2a7c1 100644 --- a/src/common/helpers/inquiry-response.helper.ts +++ b/src/common/helpers/inquiry-response.helper.ts @@ -1,5 +1,6 @@ import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { translateError } from './translate-error.helper'; export function buildInquiryResponse>(params: { success: boolean; @@ -10,14 +11,17 @@ export function buildInquiryResponse>(params: { data?: T | null; error?: NormalizedErrorDto | null; }): BaseInquiryResponseDto { + const error = params.success ? null : (params.error != null ? translateError(params.error) : null); + return { success: params.success, provider: params.provider, trackingCode: params.trackingCode, - message: params.message, + message: params.success ? params.message : undefined, + messageFa: undefined, duration: params.duration, data: params.success ? (params.data ?? null) : null, - error: params.success ? null : (params.error ?? null), + error, }; } diff --git a/src/common/helpers/translate-error.helper.ts b/src/common/helpers/translate-error.helper.ts new file mode 100644 index 0000000..5e74da4 --- /dev/null +++ b/src/common/helpers/translate-error.helper.ts @@ -0,0 +1,60 @@ +import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { + buildNormalizedError, + isAppErrorCode, + resolveErrorMessageFa, + resolveErrorCodeFromMessage, +} from '../constants/error-messages'; + +/** + * Ensures a NormalizedErrorDto has its `messageFa` field populated. + * + * Resolution order: + * 1. If `messageFa` is already set, return as-is. + * 2. If a broad gateway error carries a known legacy message, map it to that catalog code. + * 3. Use the gateway-owned code-based translation map. + * 4. If nothing matches, return unchanged. + */ +export function translateError(error: NormalizedErrorDto): NormalizedErrorDto { + if (error.messageFa) { + return error; + } + + // Build interpolation context from the error + const params: Record = {}; + if (error.providerCode) params.providerCode = error.providerCode; + if (error.providerTrackingCode) params.providerTrackingCode = error.providerTrackingCode; + + if (isAppErrorCode(error.code)) { + if (error.code === 'HTTP_ERROR' || error.code === 'INTERNAL_ERROR') { + const messageCode = resolveErrorCodeFromMessage(error.message); + if (messageCode) { + return buildNormalizedError(messageCode, { + message: error.message, + messageFa: error.messageFa, + providerMessage: error.providerMessage, + providerCode: error.providerCode, + providerTrackingCode: error.providerTrackingCode, + details: error.details, + conflict: error.conflict, + }); + } + } + + return buildNormalizedError(error.code, { + message: error.message, + messageFa: error.messageFa, + providerMessage: error.providerMessage, + providerCode: error.providerCode, + providerTrackingCode: error.providerTrackingCode, + details: error.details, + conflict: error.conflict, + }); + } + + const messageFa = resolveErrorMessageFa(error.code, params); + if (messageFa) return { ...error, messageFa }; + + // Last resort: return unchanged + return error; +} diff --git a/src/common/helpers/validation-error.helper.ts b/src/common/helpers/validation-error.helper.ts index 08d802f..a403272 100644 --- a/src/common/helpers/validation-error.helper.ts +++ b/src/common/helpers/validation-error.helper.ts @@ -1,11 +1,126 @@ import { ValidationError } from 'class-validator'; import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { buildNormalizedError } from '../constants/error-messages'; export interface ValidationFieldError { field: string; constraints: string[]; } +const FIELD_LABELS_FA: Record = { + nationalCode: 'کد ملی', + postalCode: 'کد پستی', + birthDate: 'تاریخ تولد', + mobileNo: 'شماره موبایل', + sheba: 'شماره شبا', + chassisNo: 'شماره شاسی', + plk1: 'بخش اول پلاک', + plk2: 'حرف پلاک', + plk3: 'بخش سوم پلاک', + plksrl: 'سری پلاک', + email: 'ایمیل', + password: 'رمز عبور', + newPassword: 'رمز عبور جدید', + role: 'نقش کاربر', + clientType: 'نوع کاربر', + username: 'نام کاربری', + fullName: 'نام کامل', +}; + +const CONSTRAINT_PRIORITY = [ + 'isNotEmpty', + 'matches', + 'isLength', + 'length', + 'minLength', + 'maxLength', + 'isEmail', + 'isEnum', + 'isIn', + 'isString', + 'isInt', + 'isBoolean', + 'isArray', +]; + +function pickConstraint(constraints: Record): string { + for (const key of CONSTRAINT_PRIORITY) { + if (constraints[key]) { + return constraints[key]; + } + } + + return Object.values(constraints)[0] ?? 'Invalid value'; +} + +function fieldLabelFa(field: string): string { + return FIELD_LABELS_FA[field] ?? field; +} + +function translateValidationConstraint(field: string, constraint: string): string { + const label = fieldLabelFa(field); + + if (constraint.includes('must be exactly 10 digits')) { + return `${label} باید دقیقا ۱۰ رقم باشد`; + } + + if (constraint.includes('birthDate must be in YYYY-MM-DD format')) { + return 'تاریخ تولد باید با فرمت YYYY-MM-DD باشد'; + } + + if (constraint.includes('birthDate must be in YYYY-MM-DD or YYYYMMDD format')) { + return 'تاریخ تولد باید با فرمت YYYY-MM-DD یا YYYYMMDD باشد'; + } + + if (constraint.includes('mobileNo must be an Iranian mobile number')) { + return 'شماره موبایل باید معتبر و با 09 شروع شود'; + } + + if (constraint.includes('sheba must start with IR and contain 24 digits')) { + return 'شماره شبا باید با IR شروع شود و ۲۴ رقم داشته باشد'; + } + + const exactDigits = constraint.match(/must be exactly (\d+) digits/); + if (exactDigits?.[1]) { + return `${label} باید دقیقا ${exactDigits[1]} رقم باشد`; + } + + const minLength = constraint.match(/must be longer than or equal to (\d+) characters/); + if (minLength?.[1]) { + return `${label} باید حداقل ${minLength[1]} کاراکتر باشد`; + } + + if (constraint.includes('must be an email')) { + return 'ایمیل باید معتبر باشد'; + } + + if (constraint.includes('must be one of the following values')) { + return `${label} مقدار معتبری ندارد`; + } + + if (constraint.includes('should not be empty')) { + return `${label} الزامی است`; + } + + if (constraint.includes('must be a string')) { + return `${label} باید متن باشد`; + } + + if (constraint.includes('must be an integer number')) { + return `${label} باید عدد صحیح باشد`; + } + + if (constraint.includes('must be a boolean value')) { + return `${label} باید مقدار درست یا نادرست باشد`; + } + + if (constraint.includes('must be an array')) { + return `${label} باید آرایه باشد`; + } + + return `${label} معتبر نیست`; +} + export function flattenValidationErrors( errors: ValidationError[], parentPath = '', @@ -18,7 +133,7 @@ export function flattenValidationErrors( if (error.constraints) { result.push({ field, - constraints: Object.values(error.constraints), + constraints: [pickConstraint(error.constraints)], }); } @@ -34,18 +149,19 @@ export function buildValidationErrorResponse(errors: ValidationError[]): { normalizedError: NormalizedErrorDto; details: ValidationFieldError[]; } { - const details = flattenValidationErrors(errors); - const summary = - details.length > 0 - ? details.map((d) => `${d.field}: ${d.constraints.join(', ')}`).join('; ') - : 'Request validation failed'; + const details = flattenValidationErrors(errors).slice(0, 1); + const selected = details[0]; + const summary = selected?.constraints[0] ?? 'Request validation failed'; + const messageFa = selected + ? translateValidationConstraint(selected.field, selected.constraints[0] ?? '') + : 'اطلاعات ورودی نامعتبر است'; return { - normalizedError: { - code: 'VALIDATION_ERROR', + normalizedError: buildNormalizedError('VALIDATION_ERROR', { message: summary, + messageFa, details, - }, + }), details, }; } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 8e179ae..78d38ca 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -27,23 +27,6 @@ export interface AmitisAuthServiceConfig { enabled: boolean; } -export interface FanavaranConfig { - enabled: boolean; - baseUrl: string; - timeout: number; - appName: string; - appSecret: string; - userName: string; - password: string; - corpId: number; - contractId: number; - policyInquiryLocation: number; - claimSubmitLocation: number; - defaultClaimExpertId: number; - appTokenTtlSeconds: number; - authTokenTtlSeconds: number; -} - export interface TejaratNouConfig { enabled: boolean; timeout: number; @@ -98,24 +81,6 @@ export default () => ({ hamta: buildProviderConfig('HAMTA'), moallem: buildProviderConfig('MOALLEM'), parsian: buildProviderConfig('PARSIAN'), - fanavaran: { - enabled: process.env.FANAVARAN_ENABLED !== 'false', - baseUrl: - process.env.FANAVARAN_BASE_URL ?? - 'https://apimanager.iraneit.com/BimeApiManager', - timeout: parseInt(process.env.FANAVARAN_TIMEOUT ?? '30000', 10), - appName: process.env.FANAVARAN_APP_NAME ?? '', - appSecret: process.env.FANAVARAN_APP_SECRET ?? '', - userName: process.env.FANAVARAN_USERNAME ?? '', - password: process.env.FANAVARAN_PASSWORD ?? '', - corpId: parseInt(process.env.FANAVARAN_CORP_ID ?? '543', 10), - contractId: parseInt(process.env.FANAVARAN_CONTRACT_ID ?? '28', 10), - policyInquiryLocation: parseInt(process.env.FANAVARAN_POLICY_INQUIRY_LOCATION ?? '100100', 10), - claimSubmitLocation: parseInt(process.env.FANAVARAN_CLAIM_SUBMIT_LOCATION ?? '210050', 10), - defaultClaimExpertId: parseInt(process.env.FANAVARAN_DEFAULT_CLAIM_EXPERT_ID ?? '4662', 10), - appTokenTtlSeconds: parseInt(process.env.FANAVARAN_APP_TOKEN_TTL_SECONDS ?? '3600', 10), - authTokenTtlSeconds: parseInt(process.env.FANAVARAN_AUTH_TOKEN_TTL_SECONDS ?? '3600', 10), - } satisfies FanavaranConfig, tejaratnou: { enabled: process.env.TEJARATNOU_ENABLED !== 'false', timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), diff --git a/src/inquiry/dto/generic-inquiry-response.dto.ts b/src/inquiry/dto/generic-inquiry-response.dto.ts index 3fc6579..98b59af 100644 --- a/src/inquiry/dto/generic-inquiry-response.dto.ts +++ b/src/inquiry/dto/generic-inquiry-response.dto.ts @@ -14,6 +14,9 @@ export class GenericInquiryResponseDto { @ApiPropertyOptional() message?: string; + @ApiPropertyOptional() + messageFa?: string; + @ApiPropertyOptional({ type: Object }) data?: Record; diff --git a/src/inquiry/dto/person-inquiry-response.dto.ts b/src/inquiry/dto/person-inquiry-response.dto.ts index de034be..67f8021 100644 --- a/src/inquiry/dto/person-inquiry-response.dto.ts +++ b/src/inquiry/dto/person-inquiry-response.dto.ts @@ -16,6 +16,9 @@ export class PersonInquiryResponseDto { @ApiPropertyOptional() message?: string; + @ApiPropertyOptional() + messageFa?: string; + @ApiPropertyOptional({ type: PersonInquiryDataDto }) data?: PersonInquiryDataDto; diff --git a/src/inquiry/inquiry.service.ts b/src/inquiry/inquiry.service.ts index f5186e9..bceaf38 100644 --- a/src/inquiry/inquiry.service.ts +++ b/src/inquiry/inquiry.service.ts @@ -8,6 +8,8 @@ import { generateTrackingCode } from '../common/helpers/tracking-code.helper'; import { buildInquiryResponse } from '../common/helpers/inquiry-response.helper'; import { InquiryException } from '../common/exceptions/inquiry.exception'; import { InquiryLogService } from '../logging/inquiry-log.service'; +import { translateError } from '../common/helpers/translate-error.helper'; +import { buildNormalizedError } from '../common/constants/error-messages'; import { LegacyInquiryPayload, PersonInquiryPayload, @@ -159,18 +161,17 @@ export class InquiryService { provider?: string; } { if (error instanceof InquiryException) { - return { normalized: error.normalizedError, provider: error.provider }; + return { normalized: translateError(error.normalizedError), provider: error.provider }; } if (error && typeof error === 'object' && 'normalizedError' in error) { return { - normalized: (error as { normalizedError: NormalizedErrorDto }).normalizedError, + normalized: translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError), }; } return { - normalized: { - code: 'INQUIRY_FAILED', + normalized: buildNormalizedError('INQUIRY_FAILED', { message: error instanceof Error ? error.message : 'Inquiry failed', - }, + }), }; } diff --git a/src/main.ts b/src/main.ts index 0198586..e0f00ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import './telemetry'; -import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -8,6 +8,7 @@ import { AppModule } from './app.module'; import { API_KEY_HEADER } from './common/constants/app.constants'; import { OpenTelemetryNestLogger } from './otel-nest-logger'; import { buildValidationErrorResponse } from './common/helpers/validation-error.helper'; +import { AppException } from './common/exceptions/app-exception'; import { recordHttpRequestEnd, recordHttpRequestStart, @@ -49,9 +50,10 @@ async function bootstrap(): Promise { transformOptions: { enableImplicitConversion: true }, exceptionFactory: (errors) => { const { normalizedError } = buildValidationErrorResponse(errors); - return new BadRequestException({ + return new AppException('VALIDATION_ERROR', { message: normalizedError.message, - error: normalizedError, + messageFa: normalizedError.messageFa, + details: normalizedError.details, }); }, }), diff --git a/src/providers/base/base-provider.abstract.ts b/src/providers/base/base-provider.abstract.ts index 2ae22ba..dfa60e1 100644 --- a/src/providers/base/base-provider.abstract.ts +++ b/src/providers/base/base-provider.abstract.ts @@ -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 } protected normalizeError(partial: Partial): 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 extras?: Pick, ): 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 } } - 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( diff --git a/src/providers/implementations/amitis.provider.ts b/src/providers/implementations/amitis.provider.ts index 3a6284e..0c9a8a9 100644 --- a/src/providers/implementations/amitis.provider.ts +++ b/src/providers/implementations/amitis.provider.ts @@ -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); } } diff --git a/src/providers/implementations/tejaratnou.provider.ts b/src/providers/implementations/tejaratnou.provider.ts index 61dd738..2773b16 100644 --- a/src/providers/implementations/tejaratnou.provider.ts +++ b/src/providers/implementations/tejaratnou.provider.ts @@ -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 { 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', - }; + }); } } diff --git a/src/rate-limit/user-rate-limit.guard.ts b/src/rate-limit/user-rate-limit.guard.ts index 290ce2b..6e6d4f6 100644 --- a/src/rate-limit/user-rate-limit.guard.ts +++ b/src/rate-limit/user-rate-limit.guard.ts @@ -1,14 +1,13 @@ import { CanActivate, ExecutionContext, - HttpException, - HttpStatus, Injectable, } from '@nestjs/common'; import { Request } from 'express'; import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface'; import { UsersService } from '../users/users.service'; import { UserRateLimitService } from './user-rate-limit.service'; +import { AppException } from '../common/exceptions/app-exception'; /** * Per-user rate limits from the user document (requestLimitPerMinute / requestLimitPerDay). @@ -36,7 +35,7 @@ export class UserRateLimitGuard implements CanActivate { ); if (!allowed) { - throw new HttpException('User rate limit exceeded', HttpStatus.TOO_MANY_REQUESTS); + throw new AppException('RATE_LIMIT_EXCEEDED'); } void this.usersService.incrementTotalRequests(user.id); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 086f31f..65e0dfe 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,9 +1,5 @@ import { - BadRequestException, - ConflictException, - ForbiddenException, Injectable, - NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; @@ -19,6 +15,7 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { UserMapper } from './mappers/user.mapper'; import { User, UserDocument } from './schemas/user.schema'; import { UserResponseDto } from './dto/user-response.dto'; +import { AppException } from '../common/exceptions/app-exception'; export interface RequestContext { ip?: string; @@ -36,7 +33,7 @@ export class UsersService { async findById(id: string): Promise { const user = await this.userModel.findById(id); if (!user) { - throw new NotFoundException('User not found'); + throw new AppException('USER_NOT_FOUND'); } return user; } @@ -68,7 +65,7 @@ export class UsersService { $or: [{ username }, { email }], }); if (existing) { - throw new ConflictException('Username or email already exists'); + throw new AppException('USERNAME_OR_EMAIL_EXISTS'); } const passwordHash = await this.passwordService.hash(dto.password); @@ -117,7 +114,7 @@ export class UsersService { const email = dto.email.toLowerCase().trim(); const conflict = await this.userModel.findOne({ email, _id: { $ne: id } }); if (conflict) { - throw new ConflictException('Email already in use'); + throw new AppException('EMAIL_ALREADY_IN_USE'); } user.email = email; } @@ -149,7 +146,7 @@ export class UsersService { ): Promise { const user = await this.findById(id); if (user._id.toString() === actor.id) { - throw new BadRequestException('Cannot block your own account'); + throw new AppException('CANNOT_BLOCK_SELF'); } user.isBlocked = true; user.refreshToken = undefined; @@ -194,7 +191,7 @@ export class UsersService { ): Promise { const user = await this.userModel.findById(id).select('+password +refreshToken'); if (!user) { - throw new NotFoundException('User not found'); + throw new AppException('USER_NOT_FOUND'); } user.password = await this.passwordService.hash(dto.newPassword); @@ -238,10 +235,10 @@ export class UsersService { private assertCanAssignRole(actorRole: Role, targetRole: Role): void { if (targetRole === Role.SUPER_ADMIN && actorRole !== Role.SUPER_ADMIN) { - throw new ForbiddenException('Only SUPER_ADMIN can assign SUPER_ADMIN role'); + throw new AppException('SUPER_ADMIN_ROLE_REQUIRED'); } if (!ADMIN_ROLES.includes(actorRole)) { - throw new ForbiddenException('Insufficient permissions to manage users'); + throw new AppException('INSUFFICIENT_USER_MANAGEMENT_PERMISSION'); } } diff --git a/test/admin/admin.e2e-spec.ts b/test/admin/admin.e2e-spec.ts new file mode 100644 index 0000000..dbbac4f --- /dev/null +++ b/test/admin/admin.e2e-spec.ts @@ -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 = {}) { + 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'); +}); diff --git a/test/integration/inquiry.e2e-spec.ts b/test/integration/inquiry.e2e-spec.ts new file mode 100644 index 0000000..43aab9b --- /dev/null +++ b/test/integration/inquiry.e2e-spec.ts @@ -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); + }); + }); + }); +}); diff --git a/test/smoke/providers.smoke-spec.ts b/test/smoke/providers.smoke-spec.ts new file mode 100644 index 0000000..fd57a7b --- /dev/null +++ b/test/smoke/providers.smoke-spec.ts @@ -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; + 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; + 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((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), + }); + }); +}); diff --git a/test/support/app.ts b/test/support/app.ts new file mode 100644 index 0000000..afa1417 --- /dev/null +++ b/test/support/app.ts @@ -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 { + 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; +} diff --git a/test/support/assertions.ts b/test/support/assertions.ts new file mode 100644 index 0000000..b57a439 --- /dev/null +++ b/test/support/assertions.ts @@ -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), + }, + }); +} diff --git a/test/support/auth.ts b/test/support/auth.ts new file mode 100644 index 0000000..5fcaf77 --- /dev/null +++ b/test/support/auth.ts @@ -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 { + 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; 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; + }, + }; +} diff --git a/test/support/client-config.factory.ts b/test/support/client-config.factory.ts new file mode 100644 index 0000000..e701015 --- /dev/null +++ b/test/support/client-config.factory.ts @@ -0,0 +1,38 @@ +import { InquiryType } from '../../src/common/enums/inquiry-type.enum'; + +export interface TestClientConfig { + clientId: string; + apiKey: string; + providerBaseUrl: string; + credentials: Record; + requiresVpn: boolean; + allowedInquiries: InquiryType[]; +} + +export function createClientConfig( + overrides: Partial = {}, +): 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; +} diff --git a/test/support/inquiry-endpoints.ts b/test/support/inquiry-endpoints.ts new file mode 100644 index 0000000..16f996a --- /dev/null +++ b/test/support/inquiry-endpoints.ts @@ -0,0 +1,79 @@ +import { InquiryType } from '../../src/common/enums/inquiry-type.enum'; + +export interface InquiryEndpointCase { + name: string; + inquiryType: InquiryType; + path: string; + validPayload: Record; + invalidPayload: Record; +} + +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' }, + }, +]; diff --git a/test/support/mock-provider.ts b/test/support/mock-provider.ts new file mode 100644 index 0000000..2683924 --- /dev/null +++ b/test/support/mock-provider.ts @@ -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', + }), + }, +]; diff --git a/test/unit/helpers-and-mappers.spec.ts b/test/unit/helpers-and-mappers.spec.ts new file mode 100644 index 0000000..d42f9b1 --- /dev/null +++ b/test/unit/helpers-and-mappers.spec.ts @@ -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(` + + 200 + OK: matched + + `) as Record); + + 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'], + }, + ], + }); + }); +});