From 235368b443afe60681deaca41da77fb8bffff003 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Mon, 5 Oct 2026 01:01:17 +0330 Subject: [PATCH 1/6] test(integration): slim suite against real Postgres+Redis with faked ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.test.yml (postgres:17-alpine :5433, redis:8-alpine :6380); jest-integration.json (30s timeout, setupFiles env); setup.ts (AppModule + PORTS fakes, main.ts pipeline mirror, truncate helper); health/validation-envelope/pricing suites; test:integration(+:db) scripts; unit testPathIgnorePatterns. Also fixes 5 baseUrl-only 'src/...' imports to relative (auth module/decorators/types) — they resolved at build but never under Jest. Verified: 3 suites / 4 tests green against real services, tsc 0. --- docker-compose.test.yml | 44 ++++++ package.json | 3 + src/modules/auth/auth.decorators.ts | 2 +- src/modules/auth/auth.module.ts | 2 +- src/modules/auth/types/auth.types.ts | 4 +- src/modules/auth/types/session-data.d.ts | 2 +- test/integration/env-setup.ts | 39 +++++ test/integration/health.integration.spec.ts | 27 ++++ test/integration/pricing.integration.spec.ts | 48 ++++++ test/integration/setup.ts | 139 ++++++++++++++++++ .../validation-envelope.integration.spec.ts | 47 ++++++ test/jest-integration.json | 17 +++ 12 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 test/integration/env-setup.ts create mode 100644 test/integration/health.integration.spec.ts create mode 100644 test/integration/pricing.integration.spec.ts create mode 100644 test/integration/setup.ts create mode 100644 test/integration/validation-envelope.integration.spec.ts create mode 100644 test/jest-integration.json diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..17778d9 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,44 @@ +networks: + hambaar-network-test: + driver: bridge + driver_opts: + com.docker.network.enable_ipv6: 'false' + +services: + postgres: + image: postgres:17-alpine + container_name: hambaar-postgres-test + hostname: hambaar.postgres.test + environment: + TZ: 'Asia/Tehran' + PGTZ: 'Asia/Tehran' + POSTGRES_DB: hambaar_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + PGDATA: '/data/postgres' + ports: + - '5433:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d hambaar_test'] + interval: 5s + timeout: 10s + retries: 10 + start_period: 5s + networks: + hambaar-network-test: + + redis: + image: redis:8-alpine + container_name: hambaar-redis-test + hostname: hambaar.redis.test + command: redis-server --appendonly no + ports: + - '6380:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 10s + retries: 10 + start_period: 5s + networks: + hambaar-network-test: diff --git a/package.json b/package.json index 0c40284..9a3f0a2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:integration:db": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5433/hambaar_test prisma db push --skip-generate", + "test:integration": "npm run test:integration:db && jest --config ./test/jest-integration.json", "seed:admin": "tsx ./prisma/seeders/admin-test.seed.ts", "seed:cities": "tsx ./prisma/seeders/provinces-cities.seed.ts", "seed:models": "tsx ./prisma/seeders/vehicle-brands.seed.ts", @@ -97,6 +99,7 @@ ], "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", + "testPathIgnorePatterns": ["/node_modules/", "/test/integration"], "transform": { "^.+\\.(t|j)s$": [ "ts-jest", diff --git a/src/modules/auth/auth.decorators.ts b/src/modules/auth/auth.decorators.ts index 88974d3..03def83 100644 --- a/src/modules/auth/auth.decorators.ts +++ b/src/modules/auth/auth.decorators.ts @@ -1,5 +1,5 @@ import { SetMetadata } from '@nestjs/common'; -import { OwnershipConfig } from 'src/modules/auth/types/auth.types'; +import { OwnershipConfig } from './types/auth.types'; export const CheckOwnership = (config: OwnershipConfig) => SetMetadata('ownership', config); diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 8045a7c..8298de3 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -8,7 +8,7 @@ import { TokenModule } from '../token/token.module'; import { UserModule } from '../user/user.module'; import { PrismaModule } from '../prisma/prisma.module'; import { VehicleModule } from '../vehicle/vehicle.module'; -import { CurrentUserMiddleware } from 'src/modules/user/current-user.middleware'; +import { CurrentUserMiddleware } from '../user/current-user.middleware'; import { SmsModule } from '../sms/sms.module'; import { NotificationModule } from '../notification/notification.module'; import { diff --git a/src/modules/auth/types/auth.types.ts b/src/modules/auth/types/auth.types.ts index e2d5ed9..8592af0 100644 --- a/src/modules/auth/types/auth.types.ts +++ b/src/modules/auth/types/auth.types.ts @@ -1,7 +1,7 @@ -import { AuthTokens } from 'src/common/enums/auth.enum'; +import { AuthTokens } from '../../../common/enums/auth.enum'; import { UserStatesEnum } from './auth.enums'; import { RolesEnum } from 'generated/prisma'; -import { TransporterCompactDto } from 'src/modules/user/dto/transporter-response.dto'; +import { TransporterCompactDto } from '../../user/dto/transporter-response.dto'; export interface OtpData { code: string; diff --git a/src/modules/auth/types/session-data.d.ts b/src/modules/auth/types/session-data.d.ts index 29f081c..29e3857 100644 --- a/src/modules/auth/types/session-data.d.ts +++ b/src/modules/auth/types/session-data.d.ts @@ -1,6 +1,6 @@ import 'express-session'; import { UserStatesEnum } from './auth.enums'; -import { MatchResult } from 'src/modules/package/matching.types'; +import { MatchResult } from '../../package/matching.types'; declare module 'express-session' { interface SessionData { diff --git a/test/integration/env-setup.ts b/test/integration/env-setup.ts new file mode 100644 index 0000000..d127a74 --- /dev/null +++ b/test/integration/env-setup.ts @@ -0,0 +1,39 @@ +/** + * Jest `setupFiles` entry — runs BEFORE test-file imports are evaluated. + * + * Required because `AppModule` calls `ConfigModule.forRoot({ validate })` + * at import (decorator-evaluation) time. Setting these here (instead of in + * `beforeAll`) guarantees the validated env exists when the module graph + * loads. Values apply only when unset, so CI-provided vars always win. + * + * Must stay in sync with `setupIntegrationEnv()` in `./setup`. + */ +const DEFAULTS: Record = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://postgres:postgres@localhost:5433/hambaar_test', + REDIS_URL: 'redis://localhost:6380', + OTP_REDIS_URL: 'redis://localhost:6380/1', + SESSION_REDIS_URL: 'redis://localhost:6380/2', + SESSION_SECRET: 'test-session-secret-min-16-chars', + COOKIE_SECRET: 'test-cookie-secret', + JWT_ACCESS_SECRET_KEY: 'test-jwt-access-secret-min-16', + JWT_TEMP_SECRET_KEY: 'test-jwt-temp-secret-min-16', + JWT_PROGRESS_SECRET_KEY: 'test-jwt-progress-secret-min-16', + AWS_ACCESS_KEY: 'test', + AWS_SECRET_KEY: 'test', + AWS_BUCKET_NAME: 'test-bucket', + AWS_ENDPOINT: 'https://s3.test.local', + MAP_API_KEY: 'test', + MAP_API_URL: 'https://maps.test.local', + SMS_API_KEY: 'test', + API_PREFIX: 'api', + // The test runner's own heap dwarfs a production container's — keep the + // Terminus memory probe green without touching the 150 MB app default. + MEMORY_HEAP_THRESHOLD_MB: '4096', +}; + +for (const [key, value] of Object.entries(DEFAULTS)) { + if (!process.env[key]) process.env[key] = value; +} + +export {}; diff --git a/test/integration/health.integration.spec.ts b/test/integration/health.integration.spec.ts new file mode 100644 index 0000000..43a1ddd --- /dev/null +++ b/test/integration/health.integration.spec.ts @@ -0,0 +1,27 @@ +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { createIntegrationApp, truncateDb } from './setup'; + +/** Full AppModule against real Postgres + Redis (ports faked). */ +describe('Health (integration)', () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + app = await createIntegrationApp(); + }); + + afterAll(async () => { + await truncateDb(app); + if (app) await app.close(); + }); + + it('GET /api/health responds 200 when DB + Redis are up', async () => { + if (!app) throw new Error('integration app failed to initialize'); + const res = await request(app.getHttpServer()).get('/api/health'); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.headers['x-request-id']).toBeDefined(); + }); +}); diff --git a/test/integration/pricing.integration.spec.ts b/test/integration/pricing.integration.spec.ts new file mode 100644 index 0000000..579caf4 --- /dev/null +++ b/test/integration/pricing.integration.spec.ts @@ -0,0 +1,48 @@ +import { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createIntegrationApp, truncateDb } from './setup'; +import { PricingService } from '../../src/modules/pricing/pricing.service'; + +/** + * Pricing parity through the REAL validated ConfigService: the golden base + * case must equal the unit-test golden (170000 = 50000 base + 120000 for + * 100 km Tehran→Isfahan). Proves PRICING_* wiring end to end. + */ +describe('Pricing golden parity (integration)', () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + app = await createIntegrationApp(); + }); + + afterAll(async () => { + await truncateDb(app); + if (app) await app.close(); + }); + + it('matches the unit golden base case with live config', () => { + if (!app) throw new Error('integration app failed to initialize'); + const pricing = new PricingService(app.get(ConfigService)); + + expect( + pricing.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + isFragile: false, + isPerishable: false, + originCity: 'تهران', + destinationCity: 'اصفهان', + }), + ).toEqual({ + suggestedPrice: 170000, + breakdown: { + basePrice: 50000, + distanceCost: 120000, + weightCost: 0, + deviationCost: 0, + specialHandlingCost: 0, + cityPremiumCost: 0, + }, + }); + }); +}); diff --git a/test/integration/setup.ts b/test/integration/setup.ts new file mode 100644 index 0000000..218e8d3 --- /dev/null +++ b/test/integration/setup.ts @@ -0,0 +1,139 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../../src/modules/app/app.module'; +import { PrismaService } from '../../src/modules/prisma/prisma.service'; +import { AllExceptionsFilter } from '../../src/common/filters/all-exceptions.filter'; +import { RequestIdMiddleware } from '../../src/common/middleware/request-id.middleware'; +import { PORTS } from '../../src/infra/ports/ports.tokens'; + +/** + * Shared bootstrap for the integration suite (Phase 6 Task 1). + * + * - Env defaults target `docker-compose.test.yml` (postgres :5433, redis + * :6380) and apply only when unset, so CI-provided values always win. + * - External ports (S3/SMS/Neshan) are overridden with in-memory fakes — + * no network, no secrets (T-6). + * - HTTP pipeline mirrors `src/main.ts`: global prefix, ValidationPipe + * (whitelist + forbidNonWhitelisted), AllExceptionsFilter, request ids. + */ + +const DEFAULTS: Record = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://postgres:postgres@localhost:5433/hambaar_test', + REDIS_URL: 'redis://localhost:6380', + OTP_REDIS_URL: 'redis://localhost:6380/1', + SESSION_REDIS_URL: 'redis://localhost:6380/2', + SESSION_SECRET: 'test-session-secret-min-16-chars', + COOKIE_SECRET: 'test-cookie-secret', + JWT_ACCESS_SECRET_KEY: 'test-jwt-access-secret-min-16', + JWT_TEMP_SECRET_KEY: 'test-jwt-temp-secret-min-16', + JWT_PROGRESS_SECRET_KEY: 'test-jwt-progress-secret-min-16', + AWS_ACCESS_KEY: 'test', + AWS_SECRET_KEY: 'test', + AWS_BUCKET_NAME: 'test-bucket', + AWS_ENDPOINT: 'https://s3.test.local', + MAP_API_KEY: 'test', + MAP_API_URL: 'https://maps.test.local', + SMS_API_KEY: 'test', + API_PREFIX: 'api', + // The test runner's own heap dwarfs a production container's — keep the + // Terminus memory probe green without touching the 150 MB app default. + MEMORY_HEAP_THRESHOLD_MB: '4096', +}; + +export function setupIntegrationEnv(): void { + for (const [key, value] of Object.entries(DEFAULTS)) { + if (!process.env[key]) process.env[key] = value; + } +} + +const fakeStorage = { + generatePutPresignedUrl: async (key: string) => `https://test.local/${key}`, + generateGetPresignedUrl: async (key: string | undefined | null) => + key ? `https://test.local/${key}` : '', + deleteFile: async () => undefined, + fileExists: async () => false, +}; + +const fakeSms = { + sendSms: async () => true, + sendOtp: async () => true, +}; + +const fakeMaps = { + calculateDistance: async () => ({ distance: 1, duration: 1 }), + reverseGeocode: async () => ({ + status: 'OK', + city: 'TestCity', + county: null, + }), + getDirections: async () => ({ routes: [] }), +}; + +export async function createIntegrationApp(): Promise { + setupIntegrationEnv(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(PORTS.STORAGE) + .useValue(fakeStorage) + .overrideProvider(PORTS.SMS) + .useValue(fakeSms) + .overrideProvider(PORTS.MAPS) + .useValue(fakeMaps) + .compile(); + + const app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + app.useGlobalFilters(new AllExceptionsFilter()); + const requestIds = new RequestIdMiddleware(); + app.use((req: unknown, res: unknown, next: () => void) => + requestIds.use(req as never, res as never, next as never), + ); + await app.init(); + return app; +} + +/** + * Physical table names (see `@@map` in `prisma/schema.prisma`) truncate + * (CASCADE) — keeps suites isolated. + */ +const TABLES = [ + 'tracking_updates', + 'matched_requests', + 'trip_requests', + 'trip_waypoints', + 'trips', + 'package_recipients', + 'packages', + 'addresses', + 'transactions', + 'wallets', + 'notifications', + 'vehicles', + 'vehicle_models', + 'vehicle_brands', + 'transporters', + 'verification_status', + 'achievements', + 'cities', + 'provinces', + 'users', +]; + +export async function truncateDb( + app: INestApplication | undefined, +): Promise { + if (!app) return; + const prisma = app.get(PrismaService); + const tables = TABLES.map((t) => `"${t}"`).join(', '); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} CASCADE;`); +} diff --git a/test/integration/validation-envelope.integration.spec.ts b/test/integration/validation-envelope.integration.spec.ts new file mode 100644 index 0000000..82967da --- /dev/null +++ b/test/integration/validation-envelope.integration.spec.ts @@ -0,0 +1,47 @@ +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { createIntegrationApp, truncateDb } from './setup'; + +/** + * Standardized envelopes (Phase 2 filter + global ValidationPipe) through + * real HTTP: unknown DTO fields → 400, unknown routes → default 404. + * + * Deviation from the phase-6 spec: unmatched routes get Nest's default 404 + * body (`{statusCode, message}`) — the `AllExceptionsFilter` only shapes + * exceptions thrown inside the pipeline. The `X-Request-Id` header is still + * present on 404s via the global middleware. + */ +describe('Validation + error envelope (integration)', () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + app = await createIntegrationApp(); + }); + + afterAll(async () => { + await truncateDb(app); + if (app) await app.close(); + }); + + it('POST with an unknown field returns the 400 envelope', async () => { + if (!app) throw new Error('integration app failed to initialize'); + const res = await request(app.getHttpServer()) + .post('/api/auth/send-otp') + .send({ mobile: '09123456789', extra: 'nope' }); + + expect(res.status).toBe(400); + expect(res.body.statusCode).toBe(400); + expect(String(res.body.message)).toContain('extra'); + expect(res.headers['x-request-id']).toBeDefined(); + }); + + it('GET on an unknown route returns 404 with a request id header', async () => { + if (!app) throw new Error('integration app failed to initialize'); + const res = await request(app.getHttpServer()).get('/api/does-not-exist'); + + expect(res.status).toBe(404); + expect(res.body.statusCode).toBe(404); + expect(res.headers['x-request-id']).toBeDefined(); + }); +}); diff --git a/test/jest-integration.json b/test/jest-integration.json new file mode 100644 index 0000000..25b8402 --- /dev/null +++ b/test/jest-integration.json @@ -0,0 +1,17 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".integration.spec.ts$", + "transform": { + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "isolatedModules": true + } + ] + }, + "testTimeout": 30000, + "maxWorkers": 1, + "setupFiles": ["/integration/env-setup.ts"] +} From f1c3b48eff9b90396321b24086bb195081935e63 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Mon, 5 Oct 2026 01:03:39 +0330 Subject: [PATCH 2/6] ci(workflows): test -> integration -> build, secrets stripped, coverage artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit job keeps only NODE_ENV=test (14 leaked secrets removed; all unit specs are fully mocked). New integration job (postgres:17 + redis:8 services, dummy non-secret env, ports faked in-suite) runs db push + jest-integration. Build now needs integration and stays master-push-only. Coverage report uploaded as artifact. tsc gate added; lint gate deliberately omitted (pre-existing repo-wide CRLF debt — documented in-file). --- .github/workflows/test-build.yml | 120 +++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 66a0452..8670dc0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -6,34 +6,57 @@ on: pull_request: branches: [ "master" ] -env: - NODE_ENV: production - DATABASE_URL: postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@hambaar.postgres:5432/${{ secrets.POSTGRES_DB }} - REDIS_URL: redis://:${{ secrets.REDIS_PASSWORD }}@hambaar.redis:6379 - JWT_ACCESS_SECRET_KEY: ${{ secrets.JWT_ACCESS_SECRET_KEY }} - JWT_PROGRESS_SECRET_KEY: ${{ secrets.JWT_PROGRESS_SECRET_KEY }} - JWT_TEMP_SECRET_KEY: ${{ secrets.JWT_TEMP_SECRET_KEY }} - COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }} - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} - AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} - AWS_BUCKET_NAME: ${{ secrets.AWS_BUCKET_NAME }} - AWS_ENDPOINT: ${{ secrets.AWS_ENDPOINT }} - MAP_API_KEY: ${{ secrets.MAP_API_KEY }} - MAP_API_URL: ${{ secrets.MAP_API_URL }} - SMS_API_KEY: ${{ secrets.SMS_API_KEY }} - jobs: test: runs-on: ubuntu-latest container: node:22-bookworm-slim + # Unit suite is fully mocked (no DB/Redis/network): the only env it may + # see is NODE_ENV. No secrets here (T-6). + env: + NODE_ENV: test + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Update and Upgrade image + run: apt-get update -y + && apt-get upgrade -y + && apt-get install -y openssl + && apt-get install -y bash + + - name: Install Dependencies + run: npm ci --include=dev + + - name: Generate Prisma Client + run: npm run prisma:generate + + # No lint gate: repo-wide CRLF/prettier debt predates the refactor + # and would fail a clean check. Local workflow stays `npm run lint` + # (--fix). Tracked for a whitespace-normalization pass post-refactor. + - name: Type check + run: npx tsc --noEmit + + - name: Run Unit Tests with Coverage + run: npm test -- --coverage + + - name: Upload Coverage Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + + integration: + runs-on: ubuntu-latest + needs: test + container: node:22-bookworm-slim services: postgres: - image: postgres + image: postgres:17 env: - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: hambaar_test ports: - 5432:5432 options: >- @@ -41,9 +64,9 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - + redis: - image: redis + image: redis:8 ports: - 6379:6379 options: >- @@ -51,45 +74,64 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - + # Dummy (non-secret) values only — SMS/Neshan/S3 are faked at the port + # boundary inside the suite. NOTE: `npm run test:integration` carries + # localhost defaults for local runs, so CI invokes push + jest directly. + env: + NODE_ENV: test + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/hambaar_test + REDIS_URL: redis://redis:6379 + OTP_REDIS_URL: redis://redis:6379/1 + SESSION_REDIS_URL: redis://redis:6379/2 + SESSION_SECRET: ci-session-secret-min-16-chars + COOKIE_SECRET: ci-cookie-secret + JWT_ACCESS_SECRET_KEY: ci-jwt-access-secret-min-16 + JWT_TEMP_SECRET_KEY: ci-jwt-temp-secret-min-16 + JWT_PROGRESS_SECRET_KEY: ci-jwt-progress-secret-min-16 + AWS_ACCESS_KEY: ci + AWS_SECRET_KEY: ci + AWS_BUCKET_NAME: ci-bucket + AWS_ENDPOINT: https://s3.ci.local + MAP_API_KEY: ci + MAP_API_URL: https://maps.ci.local + SMS_API_KEY: ci steps: - name: Check out the repo uses: actions/checkout@v4 - + - name: Update and Upgrade image run: apt-get update -y && apt-get upgrade -y && apt-get install -y openssl && apt-get install -y bash - + - name: Install Dependencies run: npm ci --include=dev - - # TODO: Uncomment when add e2e tests - # - name: Push DB with prisma - # run: npm run prisma:push - + - name: Generate Prisma Client run: npm run prisma:generate - - name: Run Unit Tests - run: npm test + - name: Push Test Schema + run: npx prisma db push --skip-generate + + - name: Run Integration Tests + run: npx jest --config ./test/jest-integration.json build: runs-on: ubuntu-latest - needs: test + needs: integration if: github.event_name == 'push' && github.ref == 'refs/heads/master' - + steps: - name: Check out the repo uses: actions/checkout@v4 - + - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Extract metadata id: meta uses: docker/metadata-action@v5 @@ -99,7 +141,7 @@ jobs: type=ref,event=branch type=sha,prefix=main- latest - + - name: Build Image and Push to DockerHub uses: docker/build-push-action@v5 with: @@ -108,4 +150,4 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - target: runtime \ No newline at end of file + target: runtime From 7764508037f1dd89093bba839949555a6b6aa2e0 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Mon, 5 Oct 2026 08:27:41 +0330 Subject: [PATCH 3/6] test(e2e): smoke suite for health, validation and not-found envelopes Retires the default Hello World! scaffold; boots the full AppModule (ports faked, main.ts pipeline mirrored) against the integration compose stack. 30s timeout + shared env-setup. CI runs it after the integration suite in the same job. Verified: 3 suites / 3 tests green, tsc 0, eslint 0 errors. --- .github/workflows/test-build.yml | 3 +++ test/app.e2e-spec.ts | 25 ------------------------- test/e2e/health.e2e-spec.ts | 25 +++++++++++++++++++++++++ test/e2e/not-found.e2e-spec.ts | 29 +++++++++++++++++++++++++++++ test/e2e/validation.e2e-spec.ts | 28 ++++++++++++++++++++++++++++ test/jest-e2e.json | 4 +++- 6 files changed, 88 insertions(+), 26 deletions(-) delete mode 100644 test/app.e2e-spec.ts create mode 100644 test/e2e/health.e2e-spec.ts create mode 100644 test/e2e/not-found.e2e-spec.ts create mode 100644 test/e2e/validation.e2e-spec.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 8670dc0..3fe6f85 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -117,6 +117,9 @@ jobs: - name: Run Integration Tests run: npx jest --config ./test/jest-integration.json + - name: Run e2e Smoke Tests + run: npm run test:e2e + build: runs-on: ubuntu-latest needs: integration diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts deleted file mode 100644 index 8fce0bf..0000000 --- a/test/app.e2e-spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/modules/app/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); -}); diff --git a/test/e2e/health.e2e-spec.ts b/test/e2e/health.e2e-spec.ts new file mode 100644 index 0000000..3765c34 --- /dev/null +++ b/test/e2e/health.e2e-spec.ts @@ -0,0 +1,25 @@ +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { createIntegrationApp } from '../integration/setup'; + +/** Full-app smoke: Terminus health against real DB + Redis. */ +describe('Health (e2e)', () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + app = await createIntegrationApp(); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + it('/api/health (GET) responds 200', async () => { + if (!app) throw new Error('e2e app failed to initialize'); + const res = await request(app.getHttpServer()).get('/api/health'); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); +}); diff --git a/test/e2e/not-found.e2e-spec.ts b/test/e2e/not-found.e2e-spec.ts new file mode 100644 index 0000000..6c17677 --- /dev/null +++ b/test/e2e/not-found.e2e-spec.ts @@ -0,0 +1,29 @@ +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { createIntegrationApp } from '../integration/setup'; + +/** + * Unknown routes get Nest's default 404 body; the global request-id + * middleware still tags the response with an `X-Request-Id` header. + */ +describe('Not found (e2e)', () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + app = await createIntegrationApp(); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + it('GET on an unknown route returns 404 with a request id', async () => { + if (!app) throw new Error('e2e app failed to initialize'); + const res = await request(app.getHttpServer()).get('/api/does-not-exist'); + + expect(res.status).toBe(404); + expect(res.body.statusCode).toBe(404); + expect(res.headers['x-request-id']).toBeDefined(); + }); +}); diff --git a/test/e2e/validation.e2e-spec.ts b/test/e2e/validation.e2e-spec.ts new file mode 100644 index 0000000..9029054 --- /dev/null +++ b/test/e2e/validation.e2e-spec.ts @@ -0,0 +1,28 @@ +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { createIntegrationApp } from '../integration/setup'; + +/** Global ValidationPipe: unknown DTO fields are rejected with 400. */ +describe('Validation envelope (e2e)', () => { + let app: INestApplication | undefined; + + beforeAll(async () => { + app = await createIntegrationApp(); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + it('POST /api/auth/send-otp with an unknown field returns 400', async () => { + if (!app) throw new Error('e2e app failed to initialize'); + const res = await request(app.getHttpServer()) + .post('/api/auth/send-otp') + .send({ mobile: '09123456789', extra: 'nope' }); + + expect(res.status).toBe(400); + expect(res.body.statusCode).toBe(400); + expect(String(res.body.message)).toContain('extra'); + }); +}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..b85dd22 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,5 +5,7 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "testTimeout": 30000, + "setupFiles": ["/integration/env-setup.ts"] } From 8642a81dfbb6d7fb80c4f2c8072bd17bbccaebbe Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Mon, 5 Oct 2026 08:33:14 +0330 Subject: [PATCH 4/6] chore(ops): seed-on-boot guard, .env.example, pragmatic coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entrypoint.sh no longer seeds on boot (SEED_ON_BOOT, default false); new .env.example documents every validated key with code-accurate defaults (un-ignored via negation); pragmatic global coverageThreshold 90/70/85/90 — current 91.9/73.6/87.2/91.9 clears it (strict 100/95 deferred: trip/health/user gaps + controller decorator-branch artifact, see REFACTOR.md). --- .env.example | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + entrypoint.sh | 10 +++++-- package.json | 13 +++++++- 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48cce0b --- /dev/null +++ b/.env.example @@ -0,0 +1,82 @@ +# Copy to `.env.development` / `.env.production`. +# Docker Compose reads `./.docker/.env` instead (POSTGRES_*, REDIS_*, PORT, ...). +# Every key below is validated at boot by `src/common/config/env.validation.ts`. + +# --- Required: infrastructure --- +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/hambaar-db +REDIS_URL=redis://:redis@localhost:6379 +OTP_REDIS_URL=redis://:redis@localhost:6379/1 +SESSION_REDIS_URL=redis://:redis@localhost:6379/2 + +# --- Required: secrets (min 16 chars where noted) --- +SESSION_SECRET=change-me-min-16-chars +COOKIE_SECRET=change-me +JWT_ACCESS_SECRET_KEY=change-me-min-16-chars +JWT_TEMP_SECRET_KEY=change-me-min-16-chars +JWT_PROGRESS_SECRET_KEY=change-me-min-16-chars + +# --- Required: storage / external APIs --- +AWS_ACCESS_KEY=change-me +AWS_SECRET_KEY=change-me +AWS_BUCKET_NAME=hambaar +AWS_ENDPOINT=https://s3.example.com +MAP_API_KEY=change-me +MAP_API_URL=https://api.neshan.org/v1 +SMS_API_KEY=change-me + +# --- Optional: runtime (defaults shown) --- +# MAX_UPLOAD_SIZE_MB=10 +# JWT_ACCESS_EXPIRES_IN=20d +# JWT_TEMP_EXPIRES_IN=20m +# JWT_PROGRESS_EXPIRES_IN=1d +# PORT=3000 +# API_PREFIX=api +# CORS_ORIGINS=http://localhost:8080 +# LOG_LEVEL=log +# TIMEOUT_MS=30000 +# RESPONSE_WRAPPER_ENABLED=true +# MEMORY_HEAP_THRESHOLD_MB=150 +# COOKIE_MAX_AGE=1296000000 +# COOKIE_SECURE=false +# COOKIE_SAMESITE=lax +# COOKIE_DOMAIN= +# THROTTLE_TTL=60 +# THROTTLE_LIMIT=100 +# PRISMA_TX_TIMEOUT_MS= +# AWS_REGION=default + +# --- Optional: OTP flow --- +# OTP_EXPIRATION_TIME=120000 +# MAX_SEND_ATTEMPTS=5 +# MAX_CHECK_ATTEMPTS=10 +# SEND_WINDOW=1800000 +# BASE_BLOCK_TIME=1200000 + +# --- Optional: pricing (defaults mirror the legacy service) --- +# PRICING_BASE_PRICE=50000 +# PRICING_FUEL_RATE=200 +# PRICING_WEIGHT_BASE_RATE=10000 +# NOTE: both keys below are validated as @IsInt, so fractional values fail +# boot (known quirk — see REFACTOR.md). Only DRIVER_SHARE is read (0.7); +# PLATFORM_COMMISSION is currently unused by the engine. +# PRICING_PLATFORM_COMMISSION=0.3 +# PRICING_DRIVER_SHARE=0.7 +# PRICING_FRAGILE_MULTIPLIER=1.25 +# PRICING_PERISHABLE_MULTIPLIER=1.35 +# PRICING_BOTH_FRAGILE_PERISHABLE=1.5 +# PRICING_MAJOR_CITY_ORIGIN=0.9 +# PRICING_MAJOR_CITY_DESTINATION=1.3 +# PRICING_BOTH_MAJOR_CITIES=1.0 +# PRICING_SMALL_CITY_FACTOR=1.2 +# PRICING_DEVIATION_RATE=15000 +# PRICING_TIME_DEVIATION_RATE=5000 +# PRICING_TIER_1_RATE=1000 +# PRICING_TIER_2_RATE=950 +# PRICING_TIER_3_RATE=850 +# PRICING_TIER_4_RATE=750 +# PRICING_TIER_5_RATE=600 +# PRICING_MAJOR_CITIES=تهران,اصفهان,مشهد +# CORRIDOR_WIDTH=10 + +# --- Optional: boot behavior --- +# SEED_ON_BOOT=false diff --git a/.gitignore b/.gitignore index 521e505..8504e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ coverage-*.txt # dotenv environment variable files .env* +!.env.example # temp directory .temp diff --git a/entrypoint.sh b/entrypoint.sh index 9d14842..c1a7b3b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,10 +2,14 @@ set -e -# Run the migration +# Sync the schema (no migrations in this project — `db push` workflow) npm run prisma:push -# Run seeders -npm run seed +# Seeding is explicit and idempotent: opt in per boot with SEED_ON_BOOT=true. +# The Docker runtime target does not use this entrypoint by default +# (see Dockerfile); `npm run seed` remains the manual path. +if [ "${SEED_ON_BOOT:-false}" = "true" ]; then + npm run seed +fi exec "$@" \ No newline at end of file diff --git a/package.json b/package.json index 9a3f0a2..7969141 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,10 @@ ], "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", - "testPathIgnorePatterns": ["/node_modules/", "/test/integration"], + "testPathIgnorePatterns": [ + "/node_modules/", + "/test/integration" + ], "transform": { "^.+\\.(t|j)s$": [ "ts-jest", @@ -116,6 +119,14 @@ "!src/**/*.dto.ts", "!src/**/*.d.ts" ], + "coverageThreshold": { + "global": { + "statements": 90, + "branches": 70, + "functions": 85, + "lines": 90 + } + }, "coverageDirectory": "./coverage", "coverageReporters": [ "text", From 8e5233ad097c5f34ef37a7fef123c1a3ff4120aa Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Mon, 5 Oct 2026 08:35:47 +0330 Subject: [PATCH 5/6] docs: add REFACTOR.md, complete README without rewriting it REFACTOR.md covers Phases 1-6 (previous state, considerations, changes per area), the frontend migration digest (2.x-5.x, replacing docs/BREAKING-CHANGES.md), a before/after summary matrix, and documented deviations (pragmatic gate, slim integration matrix, 404 body shape, CRLF lint debt). README keeps its structure: corrected token lifetimes, pricing numbers, and env defaults to code values; added missing env keys, upload size note, /api prefix, and the three testing tiers. --- README.md | 59 +++++++----- REFACTOR.md | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 REFACTOR.md diff --git a/README.md b/README.md index 0140944..86f6b4b 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ sequenceDiagram | **Token Type** | **Validity** | **Purpose** | **Scope** | |:---:|:---:|:---|:---| | **🔑 Temporary** | 20 minutes | Initial registration | Basic profile creation | -| **⏳ Progress** | 2 days | Multi-step registration | Transporter verification | +| **⏳ Progress** | 1 day | Multi-step registration | Transporter verification | | **✅ Access** | 20 days | Full system access | All authenticated operations | ### 🔒 Authorization Guards @@ -292,8 +292,8 @@ Our dynamic pricing system considers multiple factors for fair and competitive r | **Component** | **Default Rate** | **Description** | |:---|:---:|:---| | 🏁 **Base Price** | 50,000 IRR | Starting price for all deliveries | -| 📏 **Distance Pricing** | 1,200-600 IRR/km | Tiered rates based on total distance | -| ⚖️ **Weight Pricing** | 8,000 IRR/100g | Applied to packages over 500g | +| 📏 **Distance Pricing** | 1,000-600 IRR/km | Tiered rates based on total distance (5 tiers) | +| ⚖️ **Weight Pricing** | 10,000 IRR/100g | Applied to packages over 500g | | ⛽ **Fuel Rate** | 200 IRR/km | Fuel compensation for transporters | #### Special Handling Multipliers @@ -312,12 +312,12 @@ Major city advantages and rural area adjustments ensure fair pricing across all | **City Type** | **Multiplier** | **Description** | |:---|:---:|:---| | **Major City Origin** | 0.9x | 10% discount | -| **Major City Destination** | 1.2x | 20% premium | -| **Small Cities** | 1.15x | 15% premium | +| **Major City Destination** | 1.3x | 30% premium | +| **Small Cities** | 1.2x | 20% premium | #### Route Deviation Costs -- **Distance Deviation**: 2,000 IRR per extra kilometer -- **Time Deviation**: 1,500 IRR per 10 minutes +- **Distance Deviation**: 15,000 IRR per extra kilometer +- **Time Deviation**: 5,000 IRR per 10 minutes ### 5. 🗺️ Trip Lifecycle Management Complete workflow from trip start to completion: @@ -335,7 +335,7 @@ Complete workflow from trip start to completion: - **GPS Integration**: Location-based city and route detection ### 7. ⬆️ File Upload System -Secure file management using AWS S3: +Secure file management using AWS S3 pre-signed URLs. Send the optional `size` (bytes) query param — files declaring over `MAX_UPLOAD_SIZE_MB` (default 10) are rejected with 400: #### Upload Categories - **Transporter Documents**: @@ -490,6 +490,20 @@ MAP_API_KEY=your-neshan-api-key MAP_API_URL=https://api.neshan.org/v1 SMS_API_KEY=your-sms-api-key +# 📤 Uploads +MAX_UPLOAD_SIZE_MB=10 # per-file upload cap + +# 🌍 API Surface & Security +API_PREFIX=api +CORS_ORIGINS=http://localhost:8080 +THROTTLE_TTL=60 +THROTTLE_LIMIT=100 +JWT_ACCESS_EXPIRES_IN=20d +JWT_TEMP_EXPIRES_IN=20m +JWT_PROGRESS_EXPIRES_IN=1d +TIMEOUT_MS=30000 +LOG_LEVEL=log + # ⚙️ Optional Configuration (With default values) PORT=3000 COOKIE_MAX_AGE=1296000000 # 15 days @@ -505,7 +519,7 @@ BASE_BLOCK_TIME=20 * 60 * 1000 # in milliseconds ## Pricing envs PRICING_BASE_PRICE=50000 PRICING_FUEL_RATE=200 -PRICING_WEIGHT_BASE_RATE=8000 +PRICING_WEIGHT_BASE_RATE=10000 PRICING_PLATFORM_COMMISSION=0.3 PRICING_DRIVER_SHARE=0.7 @@ -516,23 +530,23 @@ PRICING_BOTH_FRAGILE_PERISHABLE=1.5 ### City Premium Factors PRICING_MAJOR_CITY_ORIGIN=0.9 -PRICING_MAJOR_CITY_DESTINATION=1.2 +PRICING_MAJOR_CITY_DESTINATION=1.3 PRICING_BOTH_MAJOR_CITIES=1.0 -PRICING_SMALL_CITY_FACTOR=1.15 +PRICING_SMALL_CITY_FACTOR=1.2 ### Route Deviation Costs -PRICING_DEVIATION_RATE=2000 -PRICING_TIME_DEVIATION_RATE=1500 +PRICING_DEVIATION_RATE=15000 +PRICING_TIME_DEVIATION_RATE=5000 ### Distance Tier Rates (IRR per km) -PRICING_TIER_1_RATE=1200 -PRICING_TIER_2_RATE=1000 +PRICING_TIER_1_RATE=1000 +PRICING_TIER_2_RATE=950 PRICING_TIER_3_RATE=850 PRICING_TIER_4_RATE=750 -PRICING_TIER_5_RATE=650 +PRICING_TIER_5_RATE=600 ### Major Cities (comma-separated) -PRICING_MAJOR_CITIES=تهران,اصفهان,مشهد,شیراز,تبریز,اهواز +PRICING_MAJOR_CITIES=تهران,اصفهان,مشهد ``` @@ -565,7 +579,7 @@ npm run seed ### 🌐 Access the Application - **Frontend**: http://localhost:8080 -- **Backend API**: http://localhost:3000 +- **Backend API**: http://localhost:3000/api (all routes live under the `API_PREFIX`, default `api`) - **API Documentation**: http://localhost:3000/docs --- @@ -579,15 +593,16 @@ Our comprehensive API documentation is available through Swagger UI at `/docs` w ## 🧪 Testing & CI/CD ### Testing Framework -- Automated test suites for core functionality -- Integration tests for external API interactions -- Unit tests for business logic components +- **Unit** (`npm test`, co-located `*.spec.ts`, fully mocked — no DB/Redis/network): 74 suites / 611 tests, ~91.9% statements/lines with a pragmatic global coverage gate (90/70/85/90) enforced via `coverageThreshold` +- **Integration** (`npm run test:integration`, `test/integration/`, real Postgres 17 + Redis 8 via `docker-compose.test.yml`, S3/SMS/Neshan faked at the port boundary): health, validation envelope, pricing golden parity +- **e2e smoke** (`npm run test:e2e`, `test/e2e/`, full `AppModule` boot): health, 400/404 envelopes +- Coverage reports upload as CI artifacts (`coverage/`) ### GitHub Workflow The project includes a complete CI/CD pipeline: 1. **Code Push**: Triggers automated workflow -2. **Test Execution**: Runs comprehensive test suite +2. **Test Execution**: Runs unit tests (coverage gate), integration tests (Postgres/Redis services), and e2e smoke tests 3. **Build Process**: Creates Docker images on test success 4. **Registry Push**: Pushes images to Docker Hub 5. **Deployment**: Automatic deployment to Liara Cloud diff --git a/REFACTOR.md b/REFACTOR.md new file mode 100644 index 0000000..c16f583 --- /dev/null +++ b/REFACTOR.md @@ -0,0 +1,263 @@ +# Architecture Refactoring Notes + +Documentation of technical changes, considerations, and fixes applied to the +Hambaar NestJS backend across Phases 1–6. Zero schema changes throughout +(Prisma `db push` workflow untouched); every phase ended with a green build +and a passing suite. + +--- + +## 1. Overview + +The codebase was refactored to resolve layering violations, god services, +security misconfiguration, untested branches, and missing delivery gates: + +- **Architecture**: Extracted infrastructure behind ports + adapters + (`src/infra/`), split god services (auth, financial, map, package, trip), + introduced pricing strategies, a transaction runner, and a city repository. +- **Security**: Explicit CORS allowlist, `/api` prefix, global rate limiting, + Swagger disabled in production, fail-fast env validation, 6-digit OTP. +- **Reliability**: Global `ValidationPipe` (whitelist + forbid), standardized + `{statusCode, message, error}` (+ `requestId`) error envelopes, Prisma error + mapping instead of copy-pasted `formatPrismaError` wrappers. +- **Testing**: Re-implemented unit suite (74 suites / 611 tests, ~91.9% + statements/lines), new integration suite against real Postgres 17 + Redis 8 + (ports faked, no secrets), e2e smoke suite replacing the scaffold. +- **DevOps**: CI is now test → integration → build with a coverage gate and + artifact upload; seeding is opt-in per boot; `.env.example` documents every + validated key. + +--- + +## 2. Security, Config & Bootstrap (Phase 2) + +### Previous State + +- CORS allowed unlisted origins; no API prefix; no rate limiting. +- Unknown DTO fields silently stripped (or worse, passed through); error + shapes differed per controller; Prisma errors leaked or 500'd. +- `prisma/.env` committed; `moment` in use; cookies/session ordering suspect. + +### Considerations + +- **Prefix over versioning**: a single configurable `API_PREFIX` (default + `api`) was chosen instead of URI versioning — one frontend base-URL change. +- **Fail-fast env**: Joi-style class-validator schema (`EnvSchema`) throws at + boot rather than failing on first request. + +### Changes Implemented + +- `main.ts`: helmet, compression, `parseCorsOrigins`, global prefix, + `ValidationPipe({whitelist, transform, forbidNonWhitelisted})`, + `AllExceptionsFilter`, logging/timeout/transform interceptors, + `RequestIdMiddleware`, shutdown hooks, Swagger gated to non-production. +- `AllExceptionsFilter` (+ `PrismaErrorMapper`): every error becomes + `{statusCode, message, error}` with `requestId` when the middleware ran; + removed all `formatPrismaError` wrappers. +- Token/guard consolidation (`token.guard.ts`, `multi-token.guard.ts`, + `ownership.guard.ts`, `deny-authorized.guard.ts`). +- `getDateDifference` rewritten with `date-fns` (`moment` removed). +- Frontend-visible: CORS allowlist `2.1`, 400 on unknown fields `2.2`, + `/api` prefix `2.3`, 429 rate limiting `2.5`. + +--- + +## 3. Auth, Session & OTP (Phase 3) + +### Previous State + +- 5-digit numeric OTP; OTP logic embedded in `AuthService`; session mutation + scattered; token lifetimes hard-coded; `UserService` called S3 directly. + +### Considerations + +- **OTP strength**: 6-digit zero-padded string balances SMS UX with entropy; + changing the `code` type (number → string) is a coordinated frontend change. +- **Session kept**: schema freeze ruled out session redesign, so mutation was + extracted into explicit domain functions with documented caveats instead. + +### Changes Implemented + +- `OtpService` (+ `OTP_CACHE` on named Redis stores), `AuthStateMachine`, + `session-utils`, `TokenService` public API with configurable + `JWT_*_EXPIRES_IN` (defaults `20d/20m/1d`). +- Slim `AuthService` + `TransporterSignupService`; user reads via + `S3StoragePort`. +- Frontend-visible: 6-digit OTP string `3.1`. + +--- + +## 4. Core Business: Package, Trip, Matching, Pricing, Turf (Phase 4) + +### Previous State + +- `PackageService` / `TripService` god services mixing orchestration, pricing + math, matching, and persistence with manual `.catch` blocks. +- Pricing constants inline; matching untestable; tracking endpoint exposed the + sender's raw phone number. + +### Considerations + +- **Byte-identical pricing**: the engine/strategies were extracted verbatim + and locked with golden tests before any cleanup, so price output never + drifted. +- **Runner over ad-hoc transactions**: `TransactionRunner` owns all + multi-statement writes; release-joins-caller-tx where delivery atomicity + demands it. + +### Changes Implemented + +- `TransactionRunner`; pricing engine + 6 strategies + factory + facade; + matching scorer + candidate query + corridor analyzer; trip/package/turf + application/domain splits. +- Frontend-visible: tracking phone masked (`+98•••••123`) `4.1`. + +--- + +## 5. Infrastructure Ports & Adapters (Phase 5, Task 1) + +### Previous State + +- `S3Service` / `SmsService` concrete classes imported across modules; + `MapService` mixed Neshan HTTP with Prisma city reads (audit A-2). + +### Considerations + +- **Ports, not SDKs**: plain-interface ports (`StoragePort`, `SmsPort`, + `MapsPort`) with `PORTS.*` tokens let unit tests swap fakes — the pattern + the integration suite reuses for external boundaries. +- **`CodeGeneratorPort` skipped**: `common/utils/codes.ts` was already pure + and tested; a port would add indirection without value. + +### Changes Implemented + +- `src/infra/ports/` + `S3StorageAdapter` / `SmsAdapter` / `NeshanMapsAdapter`; + `S3Service` + `SmsService` deleted, all importers migrated. +- Deviations (documented, not fixed): SMS retried once on network/5xx + (timing-only); port method kept the service name `getDirections` instead of + the spec's `routeDirections`; `MapModule` keeps its `PrismaModule` import + because `CityRepository` is provided there. + +--- + +## 6. Map Service & City Repository (Phase 5, Task 2) + +### Previous State + +- `MapService` depended on `PrismaService` and embedded route-shape math and + `console.*` logging. + +### Changes Implemented + +- `MapService` orchestrates `MapsPort` + `CityRepository` only; + `extractSignificantPoints` / `haversineDistance` exported as pure, fully + tested helpers (`route-filters.ts`); `console.*` became Nest `Logger`. + +--- + +## 7. Supporting Modules & Financial Split (Phase 5, Task 3) + +### Previous State + +- `FinancialService` god service with `number`-domain commission math on + `BigInt` columns; `NotificationService`/`VehicleService` used `console.*`; + `SupportService` 500'd on transporters without verification status; S3 + presigning had no size limit. + +### Considerations + +- **Preserved quirks**: `escrowedAmount` Int-vs-BigInt mismatch, + `totalSpent` never incremented, non-atomic add-funds-and-escrow, and the + `@IsInt`-validated commission keys are locked by tests and documented — all + need schema/product decisions, not refactor drive-bys. +- **Declared-size validation**: S3 enforces the declared `size` query param + against `MAX_UPLOAD_SIZE_MB`; cryptographic `Content-Length` presign + conditions remain a TODO in `s3.controller.ts`. + +### Changes Implemented + +- `WalletService` + `EscrowService` + thin `FinancialService` facade; + `formatMoney` single BigInt serializer; `unreadCount` + `GET + /notification/unread-count`; dashboard/vehicle/address/support cleaned + (runner, ports, `Logger`, no `formatPrismaError`). +- Frontend-visible: support 404 `5.1`, S3 size-limit 400 `5.2`, internal S3 + port migration `5.3`. + +--- + +## 8. Testing, CI & Ops (Phase 6) + +### Previous State + +- 14 secrets leaked into the unit CI job; images unpinned; no coverage gate + or artifact; default `Hello World!` e2e scaffold; `entrypoint.sh` seeded on + every boot; no `.env.example`; 5 `src/...` absolute imports that resolved + via `baseUrl` at build but never under Jest. + +### Considerations + +- **Pragmatic gate**: global `100/100/100/95` would fail (trip controller + ~78%, health ~65%, user middleware ~39%, plus a ~75% decorator-metadata + branch artifact on every controller). The enforced gate is `90/70/85/90` + with DTO/module typings excluded; current `91.9/73.6/87.2/91.9` clears it. +- **Slim integration matrix**: health, validation envelope, and pricing + golden parity prove wiring, envelopes, and config end to end; auth/users/ + packages/trips flows stay unit-covered and are the documented follow-up. +- **No lint gate in CI**: repo-wide CRLF/prettier debt predates the refactor + and would fail a clean check; local workflow stays `npm run lint --fix`. + +### Changes Implemented + +- `docker-compose.test.yml` (postgres:17-alpine `:5433`, redis:8-alpine + `:6380`); `test/jest-integration.json` (30s timeout, shared env setup); + `test/integration/setup.ts` (env defaults that CI overrides, port fakes, + `main.ts` pipeline mirror, truncate helper); 3 suites / 4 tests green + against real services; `test:integration` scripts; unit + `testPathIgnorePatterns` for `test/integration`. +- e2e smoke (`test/e2e/`: health, validation 400, not-found 404) replaces the + scaffold; shared `env-setup` fixes the import-time validation trap. +- CI `test → integration → build`: secrets stripped from unit (only + `NODE_ENV=test`), pinned images, `tsc` gate, coverage upload, e2e in the + integration job, build on master push only. +- `entrypoint.sh`: `SEED_ON_BOOT` guard (default false); `.env.example` + documents every validated key with code-accurate defaults; `prisma/.env` + stays untracked. +- Fixed the 5 absolute imports to relative (found because integration boots + the real `AppModule` — unit tests never import it). +- Deviation: unmatched routes return Nest's default 404 body (the filter only + shapes in-pipeline exceptions); the `X-Request-Id` header is still present. + +--- + +## 9. Frontend Migration Digest + +| # | Change | Action | +|---|---|---| +| 2.1 | CORS allowlist (`CORS_ORIGINS`) | Provide frontend origin(s) to backend env | +| 2.2 | Unknown body fields → 400 | Send only DTO fields | +| 2.3 | All routes under `/api/…` | Update API base URL | +| 2.5 | 429 beyond `THROTTLE_LIMIT`/`THROTTLE_TTL` | Respect 429 + `Retry-After` | +| 3.1 | 6-digit OTP string in `POST /auth/check-otp` | 6-digit input UI; send `code` as string | +| 4.1 | Tracking masks sender phone (`+98•••••123`) | Display-only; stop expecting full number | +| 5.1 | `PATCH /support/verification/transporters/:id` → 404 without status | Handle 404 in admin UI | +| 5.2 | S3 presign `size` param; over-limit → 400 | Pre-check size; surface 400 message | +| 5.3 | S3 port migration | None (internal) | + +--- + +## 10. Summary Matrix + +| Area | Before | After | +| :--- | :--- | :--- | +| **Config/Security** | Open CORS, no prefix/throttle, scattered error shapes | Allowlist, `/api`, 429s, `{statusCode,message,error}` + `requestId`, fail-fast env | +| **OTP/Auth** | 5-digit number, embedded logic, hard-coded lifetimes | 6-digit string, `OtpService` + state machine, configurable `20d/20m/1d` | +| **Pricing** | Inline constants in god services | Engine + 6 strategies + factory, golden-locked, byte-identical | +| **Matching/Trip/Package** | Untestable monoliths, raw phone leak | Scorer/query/analyzer splits, masked tracking | +| **Infra access** | `S3Service`/`SmsService` concretes, HTTP+Prisma in `MapService` | Ports + adapters, deleted services, `MapsPort`-only map | +| **Financial** | God service, `console`, 500 on missing status | Wallet/Escrow/facade, `formatMoney`, 404 guard, size-limited presigns | +| **Unit tests** | Thin mocks, ~68/60/42/67 | 74 suites / 611 green, ~91.9/73.6/87.2/91.9, pragmatic gate | +| **Integration** | None | Compose stack + 3 suites / 4 tests, ports faked, no secrets | +| **e2e** | `Hello World!` scaffold | Health/400/404 smoke against full `AppModule` | +| **CI** | Secrets in unit job, test → build | test → integration → build, secret-free unit, coverage artifact | +| **Ops/Docs** | Seed-on-boot, no env template, scattered phase docs | `SEED_ON_BOOT`, `.env.example`, this file + updated README | +| **Absolute imports** | 5 `src/...` imports (build-only resolution) | Relative everywhere; integration boots the real module graph | From 36138942a7f56da5be7a88c41e7cb6fa9ec10ff8 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Mon, 5 Oct 2026 08:37:35 +0330 Subject: [PATCH 6/6] chore(docs): remove superseded docs folder Working phase docs were gitignored/untracked; their substance (breaking-changes digest, audit disposition, per-phase record) now lives in REFACTOR.md. Drops the stale /docs ignore rule. --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8504e2f..07f30ff 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,4 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -/generated -/docs \ No newline at end of file +/generated \ No newline at end of file