From 3110252bae00a1b26aaab35c4d46a448b760a20c Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 23:59:23 +0330 Subject: [PATCH 1/5] refactor(prisma): add TransactionRunner with configurable transaction timeout --- src/common/config/config-names.ts | 1 + src/common/config/env.validation.ts | 1 + src/modules/prisma/prisma.module.ts | 5 +- src/modules/prisma/transaction-runner.spec.ts | 126 ++++++++++++++++++ src/modules/prisma/transaction-runner.ts | 102 ++++++++++++++ 5 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/modules/prisma/transaction-runner.spec.ts create mode 100644 src/modules/prisma/transaction-runner.ts diff --git a/src/common/config/config-names.ts b/src/common/config/config-names.ts index ba49421..9e4b605 100644 --- a/src/common/config/config-names.ts +++ b/src/common/config/config-names.ts @@ -6,6 +6,7 @@ export const ConfigKey = { Database: { Url: 'DATABASE_URL', + TransactionTimeoutMs: 'PRISMA_TX_TIMEOUT_MS', }, Redis: { Url: 'REDIS_URL', diff --git a/src/common/config/env.validation.ts b/src/common/config/env.validation.ts index 387f55a..2f7001e 100644 --- a/src/common/config/env.validation.ts +++ b/src/common/config/env.validation.ts @@ -61,6 +61,7 @@ export class EnvSchema { @IsOptional() @IsInt() SEND_WINDOW?: number; @IsOptional() @IsInt() BASE_BLOCK_TIME?: number; @IsOptional() @IsInt() CORRIDOR_WIDTH?: number; + @IsOptional() @IsInt() PRISMA_TX_TIMEOUT_MS?: number; @IsOptional() @IsString() AWS_REGION?: string; diff --git a/src/modules/prisma/prisma.module.ts b/src/modules/prisma/prisma.module.ts index ec0ce32..64adbcf 100644 --- a/src/modules/prisma/prisma.module.ts +++ b/src/modules/prisma/prisma.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; +import { TransactionRunner } from './transaction-runner'; @Module({ - providers: [PrismaService], - exports: [PrismaService], + providers: [PrismaService, TransactionRunner], + exports: [PrismaService, TransactionRunner], }) export class PrismaModule {} diff --git a/src/modules/prisma/transaction-runner.spec.ts b/src/modules/prisma/transaction-runner.spec.ts new file mode 100644 index 0000000..a22c43e --- /dev/null +++ b/src/modules/prisma/transaction-runner.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from './prisma.service'; +import { TransactionRunner } from './transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; + +describe('TransactionRunner', () => { + let runner: TransactionRunner; + let prisma: DeepMockProxy; + let config: DeepMockProxy; + + const createRunner = async (timeoutMs?: number) => { + config.get.mockImplementation((key: string, defaultValue?: unknown) => { + if ( + timeoutMs !== undefined && + key === 'PRISMA_TX_TIMEOUT_MS' + ) { + return timeoutMs; + } + return defaultValue; + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionRunner, + { provide: PrismaService, useValue: prisma }, + { provide: ConfigService, useValue: config }, + ], + }).compile(); + + return module.get(TransactionRunner); + }; + + beforeEach(async () => { + jest.resetAllMocks(); + prisma = mockDeep(); + config = mockDeep(); + // jest-mock-extended returns undefined by default; make $transaction + // actually invoke the callback like Prisma does. + (prisma.$transaction as unknown as jest.Mock).mockImplementation( + async (fn: (tx: unknown) => Promise) => fn({}), + ); + runner = await createRunner(); + }); + + it('delegates to $transaction with the default timeout', async () => { + const fn = jest.fn().mockResolvedValue('ok'); + + const result = await runner.run(fn); + + expect(result).toBe('ok'); + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 5000, + isolationLevel: undefined, + }); + }); + + it('uses the configured timeout from PRISMA_TX_TIMEOUT_MS', async () => { + runner = await createRunner(12345); + const fn = jest.fn().mockResolvedValue('ok'); + + await runner.run(fn); + + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 12345, + isolationLevel: undefined, + }); + }); + + it('honors a per-call timeout override', async () => { + const fn = jest.fn().mockResolvedValue(1); + + await runner.run(fn, { timeoutMs: 250 }); + + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 250, + isolationLevel: undefined, + }); + }); + + it('runIsolated forces serializable isolation unless overridden', async () => { + const fn = jest.fn().mockResolvedValue(1); + + await runner.runIsolated(fn); + expect(prisma.$transaction).toHaveBeenLastCalledWith(fn, { + timeout: 5000, + isolationLevel: 'Serializable', + }); + + await runner.runIsolated(fn, { + isolationLevel: 'ReadCommitted' as never, + }); + expect(prisma.$transaction).toHaveBeenLastCalledWith(fn, { + timeout: 5000, + isolationLevel: 'ReadCommitted', + }); + }); + + it('propagates the transaction result and rethrows non-timeout errors untouched', async () => { + const fn = jest.fn().mockRejectedValue(new Error('boom')); + + await expect(runner.run(fn)).rejects.toThrow('boom'); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('maps P2028 to TransactionTimedOutError with the effective timeout', async () => { + const p2028 = Object.assign(new Error('Timed out fetching a new page'), { + code: 'P2028', + }); + const fn = jest.fn().mockRejectedValue(p2028); + + await expect(runner.run(fn)).rejects.toMatchObject({ + name: 'TransactionTimedOutError', + message: 'Transaction exceeded the configured timeout of 5000ms', + }); + }); + + it('maps P2028 to TransactionTimedOutError with the override timeout in the message', async () => { + const p2028 = Object.assign(new Error('Timed out'), { code: 'P2028' }); + const fn = jest.fn().mockRejectedValue(p2028); + + await expect(runner.run(fn, { timeoutMs: 250 })).rejects.toMatchObject({ + message: 'Transaction exceeded the configured timeout of 250ms', + }); + }); +}); diff --git a/src/modules/prisma/transaction-runner.ts b/src/modules/prisma/transaction-runner.ts new file mode 100644 index 0000000..6cd16e2 --- /dev/null +++ b/src/modules/prisma/transaction-runner.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '../../../generated/prisma'; +import { ConfigKey } from '../../common/config/config-names'; +import { PrismaService } from './prisma.service'; +import { PrismaTransaction } from './prisma.types'; + +/** Default interactive-transaction timeout when none is configured (ms). */ +const DEFAULT_TX_TIMEOUT_MS = 5000; + +/** + * Thrown when a transaction exceeds its configured timeout (Prisma P2028). + * All other transaction errors are rethrown untouched — mapping Prisma errors + * to HTTP responses stays the responsibility of the global exceptions filter. + */ +export class TransactionTimedOutError extends Error { + constructor(timeoutMs: number) { + super(`Transaction exceeded the configured timeout of ${timeoutMs}ms`); + this.name = 'TransactionTimedOutError'; + } +} + +export interface TransactionRunnerOptions { + /** Override the configured default timeout (ms) for this transaction. */ + timeoutMs?: number; + /** Explicit isolation level — `run()` leaves it at the engine default. */ + isolationLevel?: Prisma.TransactionIsolationLevel; +} + +type TransactionFn = (tx: PrismaTransaction) => Promise; + +function isTransactionTimeoutError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'P2028' + ); +} + +/** + * Single entry point for interactive Prisma transactions. + * + * Services should run multi-statement writes through this runner instead of + * calling `prisma.$transaction` directly so that: + * - the timeout is centralized and configurable (`PRISMA_TX_TIMEOUT_MS`); + * - timeouts surface as a typed `TransactionTimedOutError` (P2028); + * - code-minting flows can opt into `runIsolated` (serializable) so unique + * constraint retries under concurrency are safe. + */ +@Injectable() +export class TransactionRunner { + private readonly defaultTimeoutMs: number; + + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { + this.defaultTimeoutMs = config.get( + ConfigKey.Database.TransactionTimeoutMs, + DEFAULT_TX_TIMEOUT_MS, + ); + } + + /** Run `fn` inside one interactive transaction (engine-default isolation). */ + run( + fn: TransactionFn, + options: TransactionRunnerOptions = {}, + ): Promise { + return this.execute(fn, options); + } + + /** + * Run `fn` inside a serializable transaction. Use for flows that mint + * unique codes or read-then-write shared rows, where concurrent + * transactions must not interleave. + */ + runIsolated( + fn: TransactionFn, + options: TransactionRunnerOptions = {}, + ): Promise { + return this.execute(fn, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + ...options, + }); + } + + private execute( + fn: TransactionFn, + { timeoutMs, isolationLevel }: TransactionRunnerOptions, + ): Promise { + const timeout = timeoutMs ?? this.defaultTimeoutMs; + return this.prisma + .$transaction(fn, { timeout, isolationLevel }) + .catch((error: unknown) => { + if (isTransactionTimeoutError(error)) { + throw new TransactionTimedOutError(timeout); + } + throw error; + }); + } +} From 79858420a5b1396eccea7e7147a068c89abdf864 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Fri, 4 Sep 2026 01:51:21 +0330 Subject: [PATCH 2/5] refactor(pricing): extract engine + strategies with golden parity specs Phase 4 core-business pricing refactor: - pricing.engine.ts: pure pipeline extracted verbatim from the legacy PricingService (identical arithmetic order and float behavior). - strategies/: distance-tier, weight, special-handling, city-premium, deviation-cost, earnings + pricing-factory wiring all PRICING_* config. - B-10 fix: tier capacities derive from cumulative bounds (upToKm - previousUpToKm) with no hand-tuned +1 constant; proven byte-identical to legacy output by a 500-check parity harness against the golden capture. - pricing.service.ts: slim DI facade preserving the public API consumed by package.service (calculateSuggestedPrice / calculateTransporterEarnings / calculateDeviationCost / calculateDistanceCost). - Table-driven specs per strategy + golden-equality engine spec (default variant, bit-exact floats) + facade spec. Verification: tsc clean; full suite 53/53 suites, 401/401 tests; pricing 9/9 suites, 91/91 tests. --- src/modules/pricing/pricing.engine.spec.ts | 165 +++++ src/modules/pricing/pricing.engine.ts | 77 +++ src/modules/pricing/pricing.service.spec.ts | 588 +++--------------- src/modules/pricing/pricing.service.ts | 230 +------ .../strategies/city-premium.strategy.spec.ts | 48 ++ .../strategies/city-premium.strategy.ts | 33 + .../deviation-cost.strategy.spec.ts | 27 + .../strategies/deviation-cost.strategy.ts | 13 + .../strategies/distance-tier.strategy.spec.ts | 72 +++ .../strategies/distance-tier.strategy.ts | 58 ++ .../strategies/earnings.strategy.spec.ts | 28 + .../pricing/strategies/earnings.strategy.ts | 11 + .../strategies/pricing-factory.spec.ts | 86 +++ .../pricing/strategies/pricing-factory.ts | 78 +++ .../special-handling.strategy.spec.ts | 29 + .../strategies/special-handling.strategy.ts | 21 + .../strategies/weight.strategy.spec.ts | 26 + .../pricing/strategies/weight.strategy.ts | 11 + 18 files changed, 880 insertions(+), 721 deletions(-) create mode 100644 src/modules/pricing/pricing.engine.spec.ts create mode 100644 src/modules/pricing/pricing.engine.ts create mode 100644 src/modules/pricing/strategies/city-premium.strategy.spec.ts create mode 100644 src/modules/pricing/strategies/city-premium.strategy.ts create mode 100644 src/modules/pricing/strategies/deviation-cost.strategy.spec.ts create mode 100644 src/modules/pricing/strategies/deviation-cost.strategy.ts create mode 100644 src/modules/pricing/strategies/distance-tier.strategy.spec.ts create mode 100644 src/modules/pricing/strategies/distance-tier.strategy.ts create mode 100644 src/modules/pricing/strategies/earnings.strategy.spec.ts create mode 100644 src/modules/pricing/strategies/earnings.strategy.ts create mode 100644 src/modules/pricing/strategies/pricing-factory.spec.ts create mode 100644 src/modules/pricing/strategies/pricing-factory.ts create mode 100644 src/modules/pricing/strategies/special-handling.strategy.spec.ts create mode 100644 src/modules/pricing/strategies/special-handling.strategy.ts create mode 100644 src/modules/pricing/strategies/weight.strategy.spec.ts create mode 100644 src/modules/pricing/strategies/weight.strategy.ts diff --git a/src/modules/pricing/pricing.engine.spec.ts b/src/modules/pricing/pricing.engine.spec.ts new file mode 100644 index 0000000..0540895 --- /dev/null +++ b/src/modules/pricing/pricing.engine.spec.ts @@ -0,0 +1,165 @@ +import { PricingEngine } from './pricing.engine'; +import { buildPricingStrategies } from './strategies/pricing-factory'; +import { PricingInput } from './pricing.types'; + +// Persian city names kept as escapes so the spec file stays ASCII on disk +// while still exercising the exact values the engine is configured with. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; +const TABRIZ = '\u062A\u0628\u0631\u06CC\u0632'; + +/** Compact pricing-input builder. */ +const T = (d: number, w: number, o: string, to: string, f = false, p = false): PricingInput => ({ + distanceKm: d, + weightGr: w, + isFragile: f, + isPerishable: p, + originCity: o, + destinationCity: to, +}); + +/** Breakdown builder with the legacy deviationCost: 0 field. */ +const B = ( + basePrice: number, + distanceCost: number, + weightCost: number, + specialHandlingCost: number, + cityPremiumCost: number, +) => ({ + basePrice, + distanceCost, + weightCost, + deviationCost: 0, + specialHandlingCost, + cityPremiumCost, +}); + +/** Golden case builder. */ +const C = ( + label: string, + input: PricingInput, + suggestedPrice: number, + distanceCost: number, + breakdown: ReturnType, +): GoldenCase => ({ label, input, suggestedPrice, distanceCost, breakdown }); + +interface GoldenCase { + label: string; + input: PricingInput; + suggestedPrice: number; + distanceCost: number; + breakdown: ReturnType; +} + +/** + * Golden values captured verbatim from the pre-refactor PricingService + * (bit-exact floats preserved). The full 5-variant capture was validated in a + * separate parity harness; the default variant is embedded below. + */ + +const GOLDEN_PRICE_CASES: GoldenCase[] = [ + C("base", T(100, 2, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("fragile", T(100, 2, TEHRAN, ISFAHAN, true, false), 213000, 120000, B(50000, 120000, 0, 53250, 0)), + C("perishable", T(100, 2, TEHRAN, ISFAHAN, false, true), 230000, 120000, B(50000, 120000, 0, 80500.00000000001, 0)), + C("both-flags", T(100, 2, TEHRAN, ISFAHAN, true, true), 255000, 120000, B(50000, 120000, 0, 127500, 0)), + C("tiny-distance-zero-weight", T(0.5, 0, TEHRAN, TEHRAN), 51000, 600, B(50000, 600, 0, 0, 0)), + C("weight-499", T(100, 499, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("weight-500", T(100, 500, TEHRAN, ISFAHAN), 220000, 120000, B(50000, 120000, 50000, 0, 0)), + C("weight-1000", T(100, 1000, TEHRAN, ISFAHAN), 270000, 120000, B(50000, 120000, 100000, 0, 0)), + C("weight-2750", T(100, 2750, TEHRAN, ISFAHAN), 445000, 120000, B(50000, 120000, 275000, 0, 0)), + C("city-nonmajor-dest", T(1, 0, TEHRAN, KARAJ), 46000, 1200, B(50000, 1200, 0, 0, 0)), + C("city-major-origin", T(100, 0, TEHRAN, KARAJ), 153000, 120000, B(50000, 120000, 0, 0, 0)), + C("city-major-dest", T(100, 0, KARAJ, TEHRAN), 221000, 120000, B(50000, 120000, 0, 0, 66300.00000000001)), + C("city-small-both", T(100, 0, KARAJ, SHIRAZ), 204000, 120000, B(50000, 120000, 0, 0, 40799.99999999999)), + C("city-both-major", T(100, 0, TEHRAN, TEHRAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("km-50.5", T(50.5, 0, TEHRAN, ISFAHAN), 111000, 60600, B(50000, 60600, 0, 0, 0)), + C("km-100", T(100, 0, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("km-101", T(101, 0, TEHRAN, ISFAHAN), 171000, 121150, B(50000, 121150, 0, 0, 0)), + C("km-150", T(150, 0, TEHRAN, ISFAHAN), 228000, 177500, B(50000, 177500, 0, 0, 0)), + C("km-300", T(300, 0, TEHRAN, ISFAHAN), 400000, 350000, B(50000, 350000, 0, 0, 0)), + C("km-301", T(301, 0, TEHRAN, ISFAHAN), 401000, 351050, B(50000, 351050, 0, 0, 0)), + C("km-600", T(600, 0, TEHRAN, ISFAHAN), 715000, 665000, B(50000, 665000, 0, 0, 0)), + C("km-601", T(601, 0, TEHRAN, ISFAHAN), 716000, 665950, B(50000, 665950, 0, 0, 0)), + C("km-1000", T(1000, 0, TEHRAN, ISFAHAN), 1095000, 1045000, B(50000, 1045000, 0, 0, 0)), + C("km-1001", T(1001, 0, TEHRAN, ISFAHAN), 1096000, 1045800, B(50000, 1045800, 0, 0, 0)), + C("km-1500", T(1500, 0, TEHRAN, ISFAHAN), 1495000, 1445000, B(50000, 1445000, 0, 0, 0)), + C("km-265-multitier", T(265, 0, TEHRAN, ISFAHAN), 360000, 309750, B(50000, 309750, 0, 0, 0)), + C("precision-150.5-2750-both", T(150.5, 2750, TEHRAN, ISFAHAN, true, true), 755000, 178075, B(50000, 178075, 275000, 377500, 0)), + C("precision-355.7-1234-perishable", T(355.7, 1234, ISFAHAN, KARAJ, false, true), 707000, 408485, B(50000, 408485, 123400, 247450.00000000006, 0)), + C("zero-everything", T(0, 0, TEHRAN, ISFAHAN), 50000, 0, B(50000, 0, 0, 0, 0)), + C("tier5-heavy", T(2500, 10000, MASHHAD, TEHRAN), 3295000, 2245000, B(50000, 2245000, 1000000, 0, 0)), +]; + +const GOLDEN_EARNINGS: Array<[string, number, number | undefined, number]> = [ + ["no-deviation", 100000, undefined, 70000], + ["with-deviation", 100000, 5000, 75000], + ["zero-price", 0, undefined, 0], + ["fractional", 123456.78, undefined, 86419], + ["fractional-with-deviation", 99999.99, 1234.56, 71234], +]; + +const GOLDEN_DEVIATION: Array<[string, number, number, number]> = [ + ["zero", 0, 0, 0], + ["typical", 5, 10, 125000], + ["fractional", 12.5, 7.5, 225000], + ["time-only", 0, 30, 150000], + ["km-only", 100, 0, 1500000], +]; + +/** Engine wired with the legacy default configuration. */ +function makeEngine(): PricingEngine { + const config = { + get: (key: string, def?: unknown) => { + const overrides: Record = { + PRICING_BASE_PRICE: 50000, + PRICING_FUEL_RATE: 200, + PRICING_WEIGHT_BASE_RATE: 10000, + 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_MAJOR_CITIES: TEHRAN + ',' + ISFAHAN + ',' + MASHHAD, + 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, + }; + return key in overrides ? overrides[key] : def; + }, + }; + return new PricingEngine(buildPricingStrategies(config as never)); +} + +describe('PricingEngine (bit-for-bit legacy parity, default variant)', () => { + it.each(GOLDEN_PRICE_CASES.map((c) => [c.label, c]))( + 'suggested price + breakdown for %s matches the legacy capture', + (_label, c) => { + const engine = makeEngine(); + expect(engine.calculateSuggestedPrice(c.input)).toEqual({ + suggestedPrice: c.suggestedPrice, + breakdown: c.breakdown, + }); + expect(engine.calculateDistanceCost(c.input.distanceKm)).toBe( + c.distanceCost, + ); + }, + ); + + it.each(GOLDEN_EARNINGS)('transporter earnings for %s', (_label, finalPrice, deviationPrice, expected) => { + expect(makeEngine().calculateTransporterEarnings(finalPrice, deviationPrice)).toBe(expected); + }); + + it.each(GOLDEN_DEVIATION)('deviation cost for %s', (_label, km, minutes, expected) => { + expect(makeEngine().calculateDeviationCost(km, minutes)).toBe(expected); + }); +}); diff --git a/src/modules/pricing/pricing.engine.ts b/src/modules/pricing/pricing.engine.ts new file mode 100644 index 0000000..e482206 --- /dev/null +++ b/src/modules/pricing/pricing.engine.ts @@ -0,0 +1,77 @@ +import { PricingInput, PricingResult } from './pricing.types'; + +import { PricingStrategies } from './strategies/pricing-factory'; + +/** + * Pure pricing pipeline extracted verbatim from the legacy `PricingService`. + * Every arithmetic expression keeps its original order and form so results + * are bit-for-bit identical (proven against a golden capture of the legacy + * implementation across 5 configuration variants — see pricing-golden.json). + */ +export class PricingEngine { + constructor(private readonly strategies: PricingStrategies) {} + + calculateSuggestedPrice(input: PricingInput): PricingResult { + const { basePrice } = this.strategies; + const distanceCost = this.strategies.distanceTierStrategy.calculate( + input.distanceKm, + ); + const weightCost = this.strategies.weightStrategy.calculate(input.weightGr); + + // Subtotal before multipliers, then special handling, then city premium — + // multiplication order matters for float parity with the legacy engine. + let subtotal = basePrice + distanceCost + weightCost; + + const specialMultiplier = + this.strategies.specialHandlingStrategy.calculate( + input.isFragile ?? false, + input.isPerishable ?? false, + ); + subtotal *= specialMultiplier; + + const cityPremium = this.strategies.cityPremiumStrategy.calculate( + input.originCity, + input.destinationCity, + ); + subtotal *= cityPremium; + + // Legacy rounding: nearest 1000 IRR. + const finalPrice = Math.round(subtotal / 1000) * 1000; + + return { + suggestedPrice: Math.floor(finalPrice), + breakdown: { + basePrice, + distanceCost, + weightCost, + deviationCost: 0, + specialHandlingCost: Math.max(0, specialMultiplier - 1) * finalPrice, + cityPremiumCost: Math.max(0, cityPremium - 1) * finalPrice, + }, + }; + } + + calculateTransporterEarnings( + finalPrice: number, + deviationPrice?: number, + ): number { + return this.strategies.earningsStrategy.calculate( + finalPrice, + deviationPrice, + ); + } + + calculateDeviationCost( + additionalKm: number, + additionalMinutes: number, + ): number { + return this.strategies.deviationCostStrategy.calculate( + additionalKm, + additionalMinutes, + ); + } + + calculateDistanceCost(distanceKm: number): number { + return this.strategies.distanceTierStrategy.calculate(distanceKm); + } +} \ No newline at end of file diff --git a/src/modules/pricing/pricing.service.spec.ts b/src/modules/pricing/pricing.service.spec.ts index 49ed8c0..dd44670 100644 --- a/src/modules/pricing/pricing.service.spec.ts +++ b/src/modules/pricing/pricing.service.spec.ts @@ -1,548 +1,112 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { PricingService } from './pricing.service'; -import { PricingInput } from './pricing.types'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; - -describe('PricingService', () => { - let service: PricingService; - let configService: DeepMockProxy; - const defaultConfig = { - PRICING_BASE_PRICE: 50000, - PRICING_FUEL_RATE: 200, - PRICING_WEIGHT_BASE_RATE: 10000, - 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_MAJOR_CITIES: 'تهران,اصفهان,مشهد', - 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, - }; +import { PricingService } from './pricing.service'; - const mockPricingInput: PricingInput = { - distanceKm: 100, - weightGr: 2, - isFragile: false, - isPerishable: false, - originCity: 'تهران', - destinationCity: 'اصفهان', +// Persian city names kept as escapes so the spec file stays ASCII on disk. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; + +/** Minimal ConfigService stand-in backed by a plain record. */ +function makeService( + overrides: Record = {}, +): PricingService { + const config = { + get: (key: string, def?: unknown) => + key in overrides ? overrides[key] : def, }; + return new PricingService(config as unknown as ConfigService); +} - beforeEach(async () => { - jest.resetAllMocks(); - - configService = mockDeep(); - - configService.get.mockImplementation((key: string, defaultValue?: any) => { - return defaultConfig[key] || defaultValue; - }); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - service = module.get(PricingService); - }); - - describe('calculateSuggestedPrice', () => { - it('should calculate basic price for normal package', async () => { - const input: PricingInput = { +describe('PricingService', () => { + it('returns the legacy suggested-price result shape (golden base case)', () => { + expect( + makeService().calculateSuggestedPrice({ distanceKm: 100, weightGr: 2, isFragile: false, isPerishable: false, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - - const result = service.calculateSuggestedPrice(input); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown).toEqual({ + originCity: TEHRAN, + destinationCity: ISFAHAN, + }), + ).toEqual({ + suggestedPrice: 170000, + breakdown: { basePrice: 50000, - distanceCost: expect.any(Number), - weightCost: expect.any(Number), + distanceCost: 120000, + weightCost: 0, deviationCost: 0, specialHandlingCost: 0, - cityPremiumCost: 0, // Both major cities - }); - }); - - it('should apply fragile multiplier', async () => { - const normalInput = { ...mockPricingInput, isFragile: false }; - const fragileInput = { ...mockPricingInput, isFragile: true }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const fragileResult = service.calculateSuggestedPrice(fragileInput); - - expect(fragileResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(fragileResult.breakdown.specialHandlingCost).toBeGreaterThan(0); - }); - - it('should apply perishable multiplier', async () => { - const normalInput = { ...mockPricingInput, isPerishable: false }; - const perishableInput = { ...mockPricingInput, isPerishable: true }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const perishableResult = service.calculateSuggestedPrice(perishableInput); - - expect(perishableResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(perishableResult.breakdown.specialHandlingCost).toBeGreaterThan(0); + cityPremiumCost: 0, + }, }); + }); - it('should apply both fragile and perishable multiplier', async () => { - const normalInput = { - ...mockPricingInput, - isFragile: false, - isPerishable: false, - }; - const fragileInput = { - ...mockPricingInput, - isFragile: true, - isPerishable: false, - }; - const bothInput = { - ...mockPricingInput, + it('delegates a flag combination to the engine (both flags)', () => { + expect( + makeService().calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, isFragile: true, isPerishable: true, - }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const fragileResult = service.calculateSuggestedPrice(fragileInput); - const bothResult = service.calculateSuggestedPrice(bothInput); - - expect(bothResult.suggestedPrice).toBeGreaterThan( - fragileResult.suggestedPrice, - ); - expect(bothResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(bothResult.breakdown.specialHandlingCost).toBeGreaterThan( - fragileResult.breakdown.specialHandlingCost, - ); - }); - - it('should calculate weight cost correctly', async () => { - const lightInput = { ...mockPricingInput, weightGr: 400 }; - const heavyInput = { ...mockPricingInput, weightGr: 5000 }; - - const lightResult = service.calculateSuggestedPrice(lightInput); - const heavyResult = service.calculateSuggestedPrice(heavyInput); - - expect(lightResult.breakdown.weightCost).toBe(0); - expect(heavyResult.breakdown.weightCost).toBeGreaterThan(0); - expect(heavyResult.suggestedPrice).toBeGreaterThan( - lightResult.suggestedPrice, - ); - }); - - it('should apply city premium for major to small city', async () => { - const majorToMajor = { - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - const majorToSmall = { - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'کرج', - }; - - const majorToMajorResult = service.calculateSuggestedPrice(majorToMajor); - const majorToSmallResult = service.calculateSuggestedPrice(majorToSmall); - - expect(majorToSmallResult.suggestedPrice).toBeLessThan( - majorToMajorResult.suggestedPrice, - ); - }); - - it('should apply city premium for small to major city', async () => { - const smallToMajor = { - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'تهران', - }; - const smallToSmall = { - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'قزوین', - }; - - const smallToMajorResult = service.calculateSuggestedPrice(smallToMajor); - const smallToSmallResult = service.calculateSuggestedPrice(smallToSmall); - - expect(smallToMajorResult.suggestedPrice).toBeGreaterThan( - smallToSmallResult.suggestedPrice, - ); - expect(smallToMajorResult.breakdown.cityPremiumCost).toBeGreaterThan(0); - }); - - it('should round final price to nearest thousand', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - expect(result.suggestedPrice % 1000).toBe(0); - }); - }); - - describe('calculateDistanceCost', () => { - it('should calculate cost for tier 1 distance (0-100km)', async () => { - const cost = service.calculateDistanceCost(50); - const expectedCost = 200 * 50 + 50 * 1000; // fuel + tier rate - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 2 distance (101-300km)', async () => { - const cost = service.calculateDistanceCost(200); - const expectedCost = - 200 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 100 * defaultConfig.PRICING_TIER_2_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 3 distance (301-600km)', async () => { - const cost = service.calculateDistanceCost(400); - const expectedCost = - 400 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 100 * defaultConfig.PRICING_TIER_3_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 4 distance (601-1000km)', async () => { - const cost = service.calculateDistanceCost(800); - const expectedCost = - 800 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 300 * defaultConfig.PRICING_TIER_3_RATE + - 200 * defaultConfig.PRICING_TIER_4_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 5 distance (1001km+)', async () => { - const cost = service.calculateDistanceCost(1200); - const expectedCost = - 1200 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 300 * defaultConfig.PRICING_TIER_3_RATE + - 400 * defaultConfig.PRICING_TIER_4_RATE + - 200 * defaultConfig.PRICING_TIER_5_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should handle zero distance', async () => { - const cost = service.calculateDistanceCost(0); - - expect(cost).toBe(0); - }); - }); - - describe('calculateTransporterEarnings', () => { - it('should calculate transporter earnings without deviation', async () => { - const finalPrice = 100000; - const earnings = service.calculateTransporterEarnings(finalPrice); - - expect(earnings).toBe(Math.floor(100000 * 0.7)); // 70000 - }); - - it('should calculate transporter earnings with deviation', async () => { - const finalPrice = 100000; - const deviationPrice = 20000; - const earnings = service.calculateTransporterEarnings( - finalPrice, - deviationPrice, - ); - - expect(earnings).toBe(Math.floor(100000 * 0.7) + 20000); // 90000 - }); - - it('should handle zero final price', async () => { - const earnings = service.calculateTransporterEarnings(0); - - expect(earnings).toBe(0); - }); + originCity: TEHRAN, + destinationCity: ISFAHAN, + }).suggestedPrice, + ).toBe(255000); }); - describe('calculateDeviationCost', () => { - it('should calculate deviation cost for distance only', async () => { - const cost = service.calculateDeviationCost(10, 0); - - expect(cost).toBe(10 * 15000); // 150000 - }); - - it('should calculate deviation cost for time only', async () => { - const cost = service.calculateDeviationCost(0, 30); + it('rounds suggested prices to the nearest 1000 IRR', () => { + // Default config: 50000 + 120000 (distance 100) = 170000; two small + // cities ×1.205 → raw subtotal 204850. Nearest-1000 rounding gives + // 205000; a truncating implementation would return 204000 instead. + const service = makeService({ PRICING_SMALL_CITY_FACTOR: 1.205 }); - expect(cost).toBe(30 * 5000); // 150000 + const result = service.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: KARAJ, + destinationCity: SHIRAZ, }); - it('should calculate combined deviation cost', async () => { - const cost = service.calculateDeviationCost(5, 20); - - expect(cost).toBe(5 * 15000 + 20 * 5000); // 175000 - }); - - it('should handle zero deviation', async () => { - const cost = service.calculateDeviationCost(0, 0); - - expect(cost).toBe(0); - }); + expect(result.suggestedPrice % 1000).toBe(0); + expect(result.suggestedPrice).toBe(205000); }); - describe('City premium calculations', () => { - it('should identify major cities correctly', async () => { - const majorToMajor = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); - - expect(majorToMajor.breakdown.cityPremiumCost).toBe(0); // Both major cities - }); - - it('should handle case insensitive city names', async () => { - const upperCase = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); + it('calculates transporter earnings with legacy floor semantics', () => { + const service = makeService(); - const mixedCase = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); - - expect(upperCase.suggestedPrice).toBe(mixedCase.suggestedPrice); - }); - - it('should apply small city factor for both non-major cities', async () => { - const result = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'قزوین', - }); - - expect(result.breakdown.cityPremiumCost).toBeGreaterThan(0); - }); + expect(service.calculateTransporterEarnings(100000, 5000)).toBe(75000); + expect(service.calculateTransporterEarnings(100000)).toBe(70000); + expect(service.calculateTransporterEarnings(123456.78)).toBe(86419); + expect(service.calculateTransporterEarnings(99999.99, 1234.56)).toBe(71234); }); - describe('Edge cases and validations', () => { - it('should handle missing optional parameters', async () => { - const minimalInput: PricingInput = { - distanceKm: 100, - weightGr: 1, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - - const result = service.calculateSuggestedPrice(minimalInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.specialHandlingCost).toBe(0); - }); - - it('should handle very large distances', async () => { - const largeDistanceInput = { - ...mockPricingInput, - distanceKm: 5000, - }; - - const result = service.calculateSuggestedPrice(largeDistanceInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.distanceCost).toBeGreaterThan(0); - }); - - it('should handle very heavy packages', async () => { - const heavyPackageInput = { - ...mockPricingInput, - weightGr: 50000, - }; - - const result = service.calculateSuggestedPrice(heavyPackageInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.weightCost).toBeGreaterThan(0); - }); - - it('should handle zero weight packages', async () => { - const zeroWeightInput = { - ...mockPricingInput, - weightGr: 0, - }; - - const result = service.calculateSuggestedPrice(zeroWeightInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.weightCost).toBe(0); - }); + it('calculates deviation cost', () => { + expect(makeService().calculateDeviationCost(5, 10)).toBe(125000); + expect(makeService().calculateDeviationCost(0, 30)).toBe(150000); }); - describe('Configuration variations', () => { - it('should use custom configuration values', async () => { - const customConfig = { - ...defaultConfig, - PRICING_BASE_PRICE: 100000, - PRICING_DRIVER_SHARE: 0.8, - }; - - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return customConfig[key] || defaultValue; - }, - ); - - // Recreate service with new config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const customService = module.get(PricingService); - - const result = customService.calculateSuggestedPrice(mockPricingInput); - const earnings = customService.calculateTransporterEarnings(100000); - - expect(result.breakdown.basePrice).toBe(100000); - expect(earnings).toBe(80000); - }); - - it('should use default values when config is missing', async () => { - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return defaultValue; - }, - ); - - // Recreate service with missing config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const defaultService = module.get(PricingService); - const result = defaultService.calculateSuggestedPrice(mockPricingInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.basePrice).toBe(50000); // Default value - }); - - it('should parse major cities from configuration', async () => { - const customConfig = { - ...defaultConfig, - PRICING_MAJOR_CITIES: 'شیراز,تبریز,کرج', - }; - - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return customConfig[key] || defaultValue; - }, - ); - - // Recreate service with new config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const customService = module.get(PricingService); - - const shirazToTabriz = customService.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'شیراز', - destinationCity: 'تبریز', - }); - - expect(shirazToTabriz.breakdown.cityPremiumCost).toBe(0); - }); + it('calculates distance cost', () => { + expect(makeService().calculateDistanceCost(150)).toBe(177500); + expect(makeService().calculateDistanceCost(0)).toBe(0); }); - describe('Price breakdown validation', () => { - it('should have all breakdown components', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - expect(result.breakdown).toHaveProperty('basePrice'); - expect(result.breakdown).toHaveProperty('distanceCost'); - expect(result.breakdown).toHaveProperty('weightCost'); - expect(result.breakdown).toHaveProperty('specialHandlingCost'); - expect(result.breakdown).toHaveProperty('deviationCost'); - expect(result.breakdown).toHaveProperty('cityPremiumCost'); + it('honours configuration overrides (customBase legacy variant)', () => { + const service = makeService({ + PRICING_BASE_PRICE: 60000, + PRICING_FUEL_RATE: 150, + PRICING_DRIVER_SHARE: 0.65, }); - it('should have positive or zero breakdown values', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - Object.values(result.breakdown).forEach((cost) => { - expect(cost).toBeGreaterThanOrEqual(0); - }); + const price = service.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + originCity: TEHRAN, + destinationCity: ISFAHAN, }); - it('should calculate total from breakdown components', async () => { - const fragileInput = { ...mockPricingInput, isFragile: true }; - const result = service.calculateSuggestedPrice(fragileInput); - - const calculatedTotal = - result.breakdown.basePrice + - result.breakdown.distanceCost + - result.breakdown.weightCost; - - expect(result.suggestedPrice).toBeGreaterThan(calculatedTotal); - }); - }); - - describe('Mathematical precision', () => { - it('should handle floating point calculations correctly', async () => { - const precisionInput = { - ...mockPricingInput, - distanceKm: 150.5, - weightGr: 2750, - }; - - const result = service.calculateSuggestedPrice(precisionInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(Number.isInteger(result.suggestedPrice)).toBe(true); - }); - - it('should maintain consistency across multiple calculations', async () => { - const result1 = service.calculateSuggestedPrice(mockPricingInput); - const result2 = service.calculateSuggestedPrice(mockPricingInput); - - expect(result1.suggestedPrice).toBe(result2.suggestedPrice); - expect(result1.breakdown).toEqual(result2.breakdown); - }); + expect(price.suggestedPrice).toBe(175000); + expect(service.calculateTransporterEarnings(175000)).toBe(113750); }); }); diff --git a/src/modules/pricing/pricing.service.ts b/src/modules/pricing/pricing.service.ts index d00924d..89854d2 100644 --- a/src/modules/pricing/pricing.service.ts +++ b/src/modules/pricing/pricing.service.ts @@ -1,231 +1,43 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PricingInput, PricingResult } from './pricing.types'; - -interface DistanceTier { - minKm: number; - maxKm: number; - ratePerKm: number; -} +import { PricingInput, PricingResult } from './pricing.types'; +import { buildPricingStrategies } from './strategies/pricing-factory'; +import { PricingEngine } from './pricing.engine'; + +/** + * Thin NestJS facade over `PricingEngine`. All arithmetic lives in + * `PricingEngine` and the strategies; this class exists only to wire the + * `PRICING_*` configuration once per boot and expose the three methods the + * rest of the codebase consumes. + */ @Injectable() export class PricingService { - private readonly basePrice: number; - private readonly fuelRate: number; - private readonly weightBaseRate: number; - private readonly platformCommission: number; - private readonly driverShare: number; - private readonly fragileMultiplier: number; - private readonly perishableMultiplier: number; - private readonly bothFragilePerishableMultiplier: number; - private readonly majorCityOrigin: number; - private readonly majorCityDestination: number; - private readonly bothMajorCities: number; - private readonly smallCityFactor: number; - private readonly deviationRate: number; - private readonly timeDeviationRate: number; - private readonly majorCities: string[]; - private readonly distanceTiers: DistanceTier[]; - - constructor(config: ConfigService) { - // Base Constants (in Iranian Rial) - this.basePrice = config.get('PRICING_BASE_PRICE', 50000); - this.fuelRate = config.get('PRICING_FUEL_RATE', 200); - this.weightBaseRate = config.get('PRICING_WEIGHT_BASE_RATE', 10000); - this.driverShare = config.get('PRICING_DRIVER_SHARE', 0.7); - this.platformCommission = 1 - this.driverShare; - - // Multipliers - this.fragileMultiplier = config.get( - 'PRICING_FRAGILE_MULTIPLIER', - 1.25, - ); - this.perishableMultiplier = config.get( - 'PRICING_PERISHABLE_MULTIPLIER', - 1.35, - ); - this.bothFragilePerishableMultiplier = config.get( - 'PRICING_BOTH_FRAGILE_PERISHABLE', - 1.5, - ); - - // City Premium Factors - this.majorCityOrigin = config.get('PRICING_MAJOR_CITY_ORIGIN', 0.9); - this.majorCityDestination = config.get( - 'PRICING_MAJOR_CITY_DESTINATION', - 1.3, - ); - this.bothMajorCities = config.get('PRICING_BOTH_MAJOR_CITIES', 1.0); - this.smallCityFactor = config.get('PRICING_SMALL_CITY_FACTOR', 1.2); - - // Route Deviation Costs - this.deviationRate = config.get('PRICING_DEVIATION_RATE', 15000); - this.timeDeviationRate = config.get( - 'PRICING_TIME_DEVIATION_RATE', - 5000, - ); + private readonly engine: PricingEngine; - // Major Cities List - const majorCitiesString = config.get( - 'PRICING_MAJOR_CITIES', - 'تهران,اصفهان,مشهد', - ); - this.majorCities = majorCitiesString.split(',').map((city) => city.trim()); - - // Distance Pricing Tiers - this.distanceTiers = [ - { - minKm: 0, - maxKm: 100, - ratePerKm: config.get('PRICING_TIER_1_RATE', 1000), - }, - { - minKm: 101, - maxKm: 300, - ratePerKm: config.get('PRICING_TIER_2_RATE', 950), - }, - { - minKm: 301, - maxKm: 600, - ratePerKm: config.get('PRICING_TIER_3_RATE', 850), - }, - { - minKm: 601, - maxKm: 1000, - ratePerKm: config.get('PRICING_TIER_4_RATE', 750), - }, - { - minKm: 1001, - maxKm: Infinity, - ratePerKm: config.get('PRICING_TIER_5_RATE', 600), - }, - ]; + constructor(configService: ConfigService) { + this.engine = new PricingEngine(buildPricingStrategies(configService)); } calculateSuggestedPrice(input: PricingInput): PricingResult { - // Base components - const basePrice = this.basePrice; - const distanceCost = this.calculateDistanceCost(input.distanceKm); - const weightCost = this.calculateWeightCost(input.weightGr); - - // Calculate subtotal before multipliers - let subtotal = basePrice + distanceCost + weightCost; - - // Apply multipliers - const specialMultiplier = this.calculateSpecialHandlingMultiplier( - input.isFragile ?? false, - input.isPerishable ?? false, - ); - subtotal *= specialMultiplier; - - // Apply city premium - const cityPremium = this.calculateCityPremium( - input.originCity, - input.destinationCity, - ); - subtotal *= cityPremium; - - // Round final price - const finalPrice = Math.round(subtotal / 1000) * 1000; - - return { - suggestedPrice: Math.floor(finalPrice), - breakdown: { - basePrice, - distanceCost, - weightCost, - deviationCost: 0, - specialHandlingCost: Math.max(0, specialMultiplier - 1) * finalPrice, - cityPremiumCost: Math.max(0, cityPremium - 1) * finalPrice, - }, - }; - } - - // Calculate distance-based cost using tiered pricing - calculateDistanceCost(distanceKm: number): number { - let totalCost = this.fuelRate * distanceKm; - let remainingDistance = distanceKm; - - for (const tier of this.distanceTiers) { - if (remainingDistance <= 0) break; - - if (tier.maxKm === Infinity) { - totalCost += remainingDistance * tier.ratePerKm; - break; - } - - const tierCapacity = tier.maxKm - tier.minKm + (tier.minKm ? 1 : 0); - const applicableDistance = Math.min(remainingDistance, tierCapacity); - - totalCost += applicableDistance * tier.ratePerKm; - remainingDistance -= applicableDistance; - } - - return totalCost; - } - - calculateTransporterEarnings(finalPrice: number, deviationPrice?: number) { - return Math.floor(finalPrice * this.driverShare + (deviationPrice ?? 0)); + return this.engine.calculateSuggestedPrice(input); } - // Platform commission - - // Calculate weight-based additional cost - private calculateWeightCost(weightGr: number): number { - if (weightGr < 500) { - return 0; // No additional cost for packages ≤ 1kg - } - return (weightGr / 100) * this.weightBaseRate; - } - - // Calculate special multipliers - private calculateSpecialHandlingMultiplier( - isFragile: boolean, - isPerishable: boolean, + calculateTransporterEarnings( + finalPrice: number, + deviationPrice?: number, ): number { - if (isFragile && isPerishable) { - return this.bothFragilePerishableMultiplier; - } else if (isFragile) { - return this.fragileMultiplier; - } else if (isPerishable) { - return this.perishableMultiplier; - } - return 1.0; + return this.engine.calculateTransporterEarnings(finalPrice, deviationPrice); } - // Calculate deviation cost (Based on both distance and duration) calculateDeviationCost( additionalKm: number, additionalMinutes: number, ): number { - const kmCost = additionalKm * this.deviationRate; - const timeCost = additionalMinutes * this.timeDeviationRate; - return kmCost + timeCost; - } - - // Calculate city-based premium factor - private calculateCityPremium( - originCity: string, - destinationCity: string, - ): number { - const originMajor = this.isMajorCity(originCity); - const destMajor = this.isMajorCity(destinationCity); - - if (originMajor && destMajor) { - return this.bothMajorCities; - } else if (originMajor && !destMajor) { - return this.majorCityOrigin; - } else if (!originMajor && destMajor) { - return this.majorCityDestination; - } else { - return this.smallCityFactor; - } + return this.engine.calculateDeviationCost(additionalKm, additionalMinutes); } - // Check if a city is considered a major city - private isMajorCity(cityName: string): boolean { - return this.majorCities.some( - (city) => city.toLowerCase() === cityName.toLowerCase(), - ); + calculateDistanceCost(distanceKm: number): number { + return this.engine.calculateDistanceCost(distanceKm); } } diff --git a/src/modules/pricing/strategies/city-premium.strategy.spec.ts b/src/modules/pricing/strategies/city-premium.strategy.spec.ts new file mode 100644 index 0000000..f3176d2 --- /dev/null +++ b/src/modules/pricing/strategies/city-premium.strategy.spec.ts @@ -0,0 +1,48 @@ +import { CityPremiumStrategy } from './city-premium.strategy'; + +// Persian city names kept as escapes so the spec file stays ASCII on disk. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; + +describe('CityPremiumStrategy', () => { + const strategy = new CityPremiumStrategy(0.9, 1.3, 1.0, 1.2, [ + TEHRAN, + ISFAHAN, + MASHHAD, + ]); + + it('major origin + major destination uses the both-major factor', () => { + expect(strategy.calculate(TEHRAN, MASHHAD)).toBe(1.0); + }); + + it('major origin only uses the origin factor', () => { + expect(strategy.calculate(TEHRAN, KARAJ)).toBe(0.9); + }); + + it('major destination only uses the destination factor', () => { + expect(strategy.calculate(KARAJ, ISFAHAN)).toBe(1.3); + }); + + it('two small cities use the small-city factor', () => { + expect(strategy.calculate(KARAJ, SHIRAZ)).toBe(1.2); + }); + + it('matches cities case-insensitively (legacy behaviour)', () => { + const latin = new CityPremiumStrategy(0.9, 1.3, 1.0, 1.2, ['Tehran']); + + expect(latin.calculate('tehran', KARAJ)).toBe(0.9); + expect(latin.calculate('TEHRAN', 'TEHRAN')).toBe(1.0); + }); + + it('honours custom factors', () => { + const custom = new CityPremiumStrategy(2, 3, 0.5, 1.1, [TEHRAN, MASHHAD]); + + expect(custom.calculate(MASHHAD, KARAJ)).toBe(2); + expect(custom.calculate(KARAJ, MASHHAD)).toBe(3); + expect(custom.calculate(MASHHAD, MASHHAD)).toBe(0.5); + expect(custom.calculate(KARAJ, SHIRAZ)).toBe(1.1); + }); +}); diff --git a/src/modules/pricing/strategies/city-premium.strategy.ts b/src/modules/pricing/strategies/city-premium.strategy.ts new file mode 100644 index 0000000..fbf9853 --- /dev/null +++ b/src/modules/pricing/strategies/city-premium.strategy.ts @@ -0,0 +1,33 @@ +/** Major-city premium factors, resolved per the legacy four-quadrant rule. */ +export class CityPremiumStrategy { + constructor( + private readonly majorCityOrigin: number, + private readonly majorCityDestination: number, + private readonly bothMajorCities: number, + private readonly smallCityFactor: number, + private readonly majorCities: string[], + ) {} + + calculate(originCity: string, destinationCity: string): number { + const originMajor = this.isMajorCity(originCity); + const destMajor = this.isMajorCity(destinationCity); + + if (originMajor && destMajor) { + return this.bothMajorCities; + } + if (originMajor) { + return this.majorCityOrigin; + } + if (destMajor) { + return this.majorCityDestination; + } + return this.smallCityFactor; + } + + /** Case-insensitive match against the configured `PRICING_MAJOR_CITIES`. */ + private isMajorCity(cityName: string): boolean { + return this.majorCities.some( + (city) => city.toLowerCase() === cityName.toLowerCase(), + ); + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts b/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts new file mode 100644 index 0000000..7b107d8 --- /dev/null +++ b/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts @@ -0,0 +1,27 @@ +import { DeviationCostStrategy } from './deviation-cost.strategy'; + +describe('DeviationCostStrategy', () => { + const strategy = new DeviationCostStrategy(15000, 5000); + + it('charges nothing for a zero deviation', () => { + expect(strategy.calculate(0, 0)).toBe(0); + }); + + it('combines km cost and time cost additively (legacy golden values)', () => { + expect(strategy.calculate(5, 10)).toBe(125000); + expect(strategy.calculate(0, 30)).toBe(150000); + expect(strategy.calculate(100, 0)).toBe(1500000); + }); + + it('handles fractional km and minutes like the legacy engine', () => { + expect(strategy.calculate(12.5, 7.5)).toBe(225000); + }); + + it('honours custom rates', () => { + const custom = new DeviationCostStrategy(100, 10); + + expect(custom.calculate(3, 4)).toBe(340); + expect(custom.calculate(0, 4)).toBe(40); + expect(custom.calculate(3, 0)).toBe(300); + }); +}); diff --git a/src/modules/pricing/strategies/deviation-cost.strategy.ts b/src/modules/pricing/strategies/deviation-cost.strategy.ts new file mode 100644 index 0000000..e3e641b --- /dev/null +++ b/src/modules/pricing/strategies/deviation-cost.strategy.ts @@ -0,0 +1,13 @@ +/** Route-deviation cost: `additionalKm × kmRate + additionalMinutes × timeRate`. */ +export class DeviationCostStrategy { + constructor( + private readonly deviationRate: number, + private readonly timeDeviationRate: number, + ) {} + + calculate(additionalKm: number, additionalMinutes: number): number { + const kmCost = additionalKm * this.deviationRate; + const timeCost = additionalMinutes * this.timeDeviationRate; + return kmCost + timeCost; + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/distance-tier.strategy.spec.ts b/src/modules/pricing/strategies/distance-tier.strategy.spec.ts new file mode 100644 index 0000000..7c95555 --- /dev/null +++ b/src/modules/pricing/strategies/distance-tier.strategy.spec.ts @@ -0,0 +1,72 @@ +import { DistanceTier, DistanceTierStrategy } from './distance-tier.strategy'; + +/** Builds the default 5-tier configuration exactly like pricing-factory does. */ +function buildDefaultTiers( + rates: number[] = [1000, 950, 850, 750, 600], +): DistanceTier[] { + const bounds: Array<[number | null, number]> = [ + [100, rates[0]], + [300, rates[1]], + [600, rates[2]], + [1000, rates[3]], + [null, rates[4]], + ]; + return bounds.map( + ([upToKm, rate], index) => + new DistanceTier( + upToKm, + rate, + index === 0 ? 0 : (bounds[index - 1][0] as number), + ), + ); +} + +describe('DistanceTier', () => { + it('derives spans from cumulative bounds with no hand-written widths', () => { + expect(buildDefaultTiers().map((tier) => tier.getSpanKm())).toEqual([ + 100, 200, 300, 400, Infinity, + ]); + }); +}); + +describe('DistanceTierStrategy (default legacy rates)', () => { + const strategy = new DistanceTierStrategy(200, buildDefaultTiers()); + + it.each([ + [0, 0], + [50.5, 60600], + [100, 120000], + [101, 121150], + [150, 177500], + [265, 309750], + [300, 350000], + [301, 351050], + [600, 665000], + [601, 665950], + [1000, 1045000], + [1001, 1045800], + [1500, 1445000], + [2500, 2245000], + ])('distance %p km costs %p (legacy golden value)', (km, expected) => { + expect(strategy.calculate(km)).toBe(expected); + }); +}); + +describe('DistanceTierStrategy (custom configuration)', () => { + it('honours custom tier rates (legacy customTiers variant)', () => { + const strategy = new DistanceTierStrategy( + 200, + buildDefaultTiers([800, 700, 600, 500, 400]), + ); + + expect(strategy.calculate(101)).toBe(100900); + expect(strategy.calculate(1001)).toBe(800600); + }); + + it('honours a custom fuel rate (legacy customBase variant)', () => { + const strategy = new DistanceTierStrategy(150, buildDefaultTiers()); + + expect(strategy.calculate(100)).toBe(115000); + expect(strategy.calculate(0.5)).toBe(575); + }); +}); diff --git a/src/modules/pricing/strategies/distance-tier.strategy.ts b/src/modules/pricing/strategies/distance-tier.strategy.ts new file mode 100644 index 0000000..0b8eecd --- /dev/null +++ b/src/modules/pricing/strategies/distance-tier.strategy.ts @@ -0,0 +1,58 @@ +/** + * One billed distance bucket. The first tier owns the open interval + * (0, upToKm]; every following tier owns (previousUpToKm, upToKm]. + * + * B-10 fix: the legacy implementation computed tier capacity as + * `maxKm - minKm + (minKm ? 1 : 0)` — a hand-tuned `+1` that silently + * compensated for its own 1-based bounds (101, 301, 601, 1001). Rewriting it + * as cumulative bounds makes the algebra explicit: with `minKm = prevMax + 1`, + * the legacy capacity `maxKm - minKm + 1` reduces exactly to + * `upToKm - previousUpToKm`, so the span below reproduces the legacy output + * byte-for-byte without any magic constant. + */ +export class DistanceTier { + constructor( + /** Cumulative upper bound in km, or `null` for the open-ended last tier. */ + public readonly upToKm: number | null, + public readonly ratePerKm: number, + /** Upper bound of the previous tier (0 for the first tier). */ + private readonly previousUpToKm: number, + ) {} + + /** Kilometres this tier bills, derived purely from the configured bounds. */ + getSpanKm(): number { + if (this.upToKm === null) { + return Infinity; + } + return this.upToKm - this.previousUpToKm; + } +} + +/** Tiered per-kilometre distance cost (plus a flat fuel rate per km). */ +export class DistanceTierStrategy { + constructor( + private readonly fuelRate: number, + private readonly tiers: DistanceTier[], + ) {} + + calculate(distanceKm: number): number { + let totalCost = this.fuelRate * distanceKm; + let remainingDistance = distanceKm; + + for (const tier of this.tiers) { + // Guard clause (B-10): stop as soon as every km has been billed so the + // open-ended tier can never re-bill, and non-positive distances exit + // before any tier arithmetic runs. + if (remainingDistance <= 0) { + break; + } + + const applicableDistance = Math.min(remainingDistance, tier.getSpanKm()); + + totalCost += applicableDistance * tier.ratePerKm; + remainingDistance -= applicableDistance; + } + + return totalCost; + } +} diff --git a/src/modules/pricing/strategies/earnings.strategy.spec.ts b/src/modules/pricing/strategies/earnings.strategy.spec.ts new file mode 100644 index 0000000..ad8401a --- /dev/null +++ b/src/modules/pricing/strategies/earnings.strategy.spec.ts @@ -0,0 +1,28 @@ +import { EarningsStrategy } from './earnings.strategy'; + +describe('EarningsStrategy', () => { + it('matches the legacy earnings table (golden default)', () => { + const strategy = new EarningsStrategy(0.7); + + expect(strategy.calculate(100000)).toBe(70000); + expect(strategy.calculate(100000, 5000)).toBe(75000); + expect(strategy.calculate(0)).toBe(0); + expect(strategy.calculate(123456.78)).toBe(86419); + expect(strategy.calculate(99999.99, 1234.56)).toBe(71234); + }); + + it('floors the final result (legacy Math.floor semantics)', () => { + const strategy = new EarningsStrategy(0.7); + + expect(strategy.calculate(99999.99)).toBe(69999); + }); + + it('honours a custom driver share', () => { + const strategy = new EarningsStrategy(0.65); + + expect(strategy.calculate(100000)).toBe(65000); + expect(strategy.calculate(100000, 5000)).toBe(70000); + expect(strategy.calculate(123456.78)).toBe(80246); + expect(strategy.calculate(99999.99, 1234.56)).toBe(66234); + }); +}); \ No newline at end of file diff --git a/src/modules/pricing/strategies/earnings.strategy.ts b/src/modules/pricing/strategies/earnings.strategy.ts new file mode 100644 index 0000000..be7e79a --- /dev/null +++ b/src/modules/pricing/strategies/earnings.strategy.ts @@ -0,0 +1,11 @@ +/** + * Driver earnings: share of the final price plus any deviation price, + * floored to a whole IRR (legacy `Math.floor` semantics preserved). + */ +export class EarningsStrategy { + constructor(private readonly driverShare: number) {} + + calculate(finalPrice: number, deviationPrice?: number): number { + return Math.floor(finalPrice * this.driverShare + (deviationPrice ?? 0)); + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/pricing-factory.spec.ts b/src/modules/pricing/strategies/pricing-factory.spec.ts new file mode 100644 index 0000000..9e7c786 --- /dev/null +++ b/src/modules/pricing/strategies/pricing-factory.spec.ts @@ -0,0 +1,86 @@ +import { ConfigService } from '@nestjs/config'; + +import { PricingEngine } from '../pricing.engine'; +import { buildPricingStrategies, parseMajorCities } from './pricing-factory'; + +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; + +function makeEngine(overrides: Record = {}): PricingEngine { + const config = { + get: (key: string, def?: unknown) => + key in overrides ? overrides[key] : def, + }; + return new PricingEngine( + buildPricingStrategies(config as unknown as ConfigService), + ); +} + +describe('parseMajorCities', () => { + it('splits and trims the comma-separated list', () => { + expect(parseMajorCities('\u062A\u0647\u0631\u0627\u0646, \u0627\u0635\u0641\u0647\u0627\u0646 ,\u0645\u0634\u0647\u062F')).toEqual([ + TEHRAN, + ISFAHAN, + MASHHAD, + ]); + }); +}); + +describe('buildPricingStrategies', () => { + it('applies legacy defaults end-to-end', () => { + const engine = makeEngine(); + + expect(engine.calculateTransporterEarnings(100000)).toBe(70000); + expect(engine.calculateDistanceCost(1000)).toBe(1045000); + + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + originCity: TEHRAN, + destinationCity: ISFAHAN, + }); + expect(price.suggestedPrice).toBe(170000); + }); + + it('honours tier-rate overrides', () => { + const engine = makeEngine({ PRICING_TIER_1_RATE: 800 }); + + // fuel 200/km + 800/km tier-1 rate for the first 100 km. + expect(engine.calculateDistanceCost(100)).toBe(100000); + }); + + it('honours base-price and driver-share overrides', () => { + const engine = makeEngine({ + PRICING_BASE_PRICE: 60000, + PRICING_DRIVER_SHARE: 0.65, + }); + + expect(engine.calculateTransporterEarnings(100000)).toBe(65000); + + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: TEHRAN, + destinationCity: ISFAHAN, + }); + expect(price.breakdown.basePrice).toBe(60000); + expect(price.suggestedPrice).toBe(180000); + }); + + it('honours major-city list overrides', () => { + const engine = makeEngine({ PRICING_MAJOR_CITIES: SHIRAZ + ',' + KARAJ }); + + // karaj→shiraz are now both major: factor 1.0 instead of 1.2. + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: KARAJ, + destinationCity: SHIRAZ, + }); + expect(price.suggestedPrice).toBe(170000); + expect(price.breakdown.cityPremiumCost).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/modules/pricing/strategies/pricing-factory.ts b/src/modules/pricing/strategies/pricing-factory.ts new file mode 100644 index 0000000..205d344 --- /dev/null +++ b/src/modules/pricing/strategies/pricing-factory.ts @@ -0,0 +1,78 @@ +import { ConfigService } from '@nestjs/config'; + +import { DistanceTier, DistanceTierStrategy } from './distance-tier.strategy'; +import { WeightStrategy } from './weight.strategy'; +import { SpecialHandlingStrategy } from './special-handling.strategy'; +import { CityPremiumStrategy } from './city-premium.strategy'; +import { DeviationCostStrategy } from './deviation-cost.strategy'; +import { EarningsStrategy } from './earnings.strategy'; + +/** + * Single place where `PRICING_*` configuration meets strategy constructors. + * Defaults mirror the legacy `PricingService` constructor exactly, so a + * missing env variable produces identical pricing to the pre-refactor engine. + */ +export function buildPricingStrategies(config: ConfigService) { + const driverShare = config.get('PRICING_DRIVER_SHARE', 0.7); + + const distanceTierStrategy = new DistanceTierStrategy( + config.get('PRICING_FUEL_RATE', 200), + buildDistanceTiers(config), + ); + + return { + distanceTierStrategy, + weightStrategy: new WeightStrategy( + config.get('PRICING_WEIGHT_BASE_RATE', 10000), + ), + specialHandlingStrategy: new SpecialHandlingStrategy( + config.get('PRICING_FRAGILE_MULTIPLIER', 1.25), + config.get('PRICING_PERISHABLE_MULTIPLIER', 1.35), + config.get('PRICING_BOTH_FRAGILE_PERISHABLE', 1.5), + ), + cityPremiumStrategy: new CityPremiumStrategy( + config.get('PRICING_MAJOR_CITY_ORIGIN', 0.9), + config.get('PRICING_MAJOR_CITY_DESTINATION', 1.3), + config.get('PRICING_BOTH_MAJOR_CITIES', 1.0), + config.get('PRICING_SMALL_CITY_FACTOR', 1.2), + parseMajorCities( + config.get('PRICING_MAJOR_CITIES', 'تهران,اصفهان,مشهد'), + ), + ), + deviationCostStrategy: new DeviationCostStrategy( + config.get('PRICING_DEVIATION_RATE', 15000), + config.get('PRICING_TIME_DEVIATION_RATE', 5000), + ), + earningsStrategy: new EarningsStrategy(driverShare), + basePrice: config.get('PRICING_BASE_PRICE', 50000), + }; +} + +export type PricingStrategies = ReturnType; + +/** Parses the comma-separated `PRICING_MAJOR_CITIES` env value. */ +export function parseMajorCities(majorCitiesString: string): string[] { + return majorCitiesString.split(',').map((city) => city.trim()); +} + +/** Cumulative distance tiers; spans are derived, never hand-written. */ +function buildDistanceTiers(config: ConfigService): DistanceTier[] { + const bounds: Array<[number | null, string]> = [ + [100, 'PRICING_TIER_1_RATE'], + [300, 'PRICING_TIER_2_RATE'], + [600, 'PRICING_TIER_3_RATE'], + [1000, 'PRICING_TIER_4_RATE'], + [null, 'PRICING_TIER_5_RATE'], + ]; + + const defaultRates = [1000, 950, 850, 750, 600]; + + return bounds.map(([upToKm, configName], index) => { + const previousUpToKm = index === 0 ? 0 : (bounds[index - 1][0] as number); + return new DistanceTier( + upToKm, + config.get(configName, defaultRates[index]), + previousUpToKm, + ); + }); +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/special-handling.strategy.spec.ts b/src/modules/pricing/strategies/special-handling.strategy.spec.ts new file mode 100644 index 0000000..f5e253f --- /dev/null +++ b/src/modules/pricing/strategies/special-handling.strategy.spec.ts @@ -0,0 +1,29 @@ +import { SpecialHandlingStrategy } from './special-handling.strategy'; + +describe('SpecialHandlingStrategy', () => { + const strategy = new SpecialHandlingStrategy(1.25, 1.35, 1.5); + + it('returns 1 when neither flag is set', () => { + expect(strategy.calculate(false, false)).toBe(1); + }); + + it('returns the fragile multiplier', () => { + expect(strategy.calculate(true, false)).toBe(1.25); + }); + + it('returns the perishable multiplier', () => { + expect(strategy.calculate(false, true)).toBe(1.35); + }); + + it('both flags win over the single-flag multipliers', () => { + expect(strategy.calculate(true, true)).toBe(1.5); + }); + + it('honours custom multipliers', () => { + const custom = new SpecialHandlingStrategy(2, 3, 5); + + expect(custom.calculate(true, false)).toBe(2); + expect(custom.calculate(false, true)).toBe(3); + expect(custom.calculate(true, true)).toBe(5); + }); +}); diff --git a/src/modules/pricing/strategies/special-handling.strategy.ts b/src/modules/pricing/strategies/special-handling.strategy.ts new file mode 100644 index 0000000..d5a50a3 --- /dev/null +++ b/src/modules/pricing/strategies/special-handling.strategy.ts @@ -0,0 +1,21 @@ +/** Fragile/perishable/both multipliers; 1 when neither flag is set. */ +export class SpecialHandlingStrategy { + constructor( + private readonly fragileMultiplier: number, + private readonly perishableMultiplier: number, + private readonly bothFragilePerishableMultiplier: number, + ) {} + + calculate(isFragile: boolean, isPerishable: boolean): number { + if (isFragile && isPerishable) { + return this.bothFragilePerishableMultiplier; + } + if (isFragile) { + return this.fragileMultiplier; + } + if (isPerishable) { + return this.perishableMultiplier; + } + return 1; + } +} diff --git a/src/modules/pricing/strategies/weight.strategy.spec.ts b/src/modules/pricing/strategies/weight.strategy.spec.ts new file mode 100644 index 0000000..89bf24c --- /dev/null +++ b/src/modules/pricing/strategies/weight.strategy.spec.ts @@ -0,0 +1,26 @@ +import { WeightStrategy } from './weight.strategy'; + +describe('WeightStrategy', () => { + const strategy = new WeightStrategy(10000); + + it('charges nothing below the 500 g threshold', () => { + expect(strategy.calculate(0)).toBe(0); + expect(strategy.calculate(1)).toBe(0); + expect(strategy.calculate(499)).toBe(0); + }); + + it('bills from exactly 500 g upward, per 100 g (threshold inclusive)', () => { + expect(strategy.calculate(500)).toBeCloseTo(50000, 6); + expect(strategy.calculate(1000)).toBeCloseTo(100000, 6); + expect(strategy.calculate(2750)).toBeCloseTo(275000, 6); + }); + + it('bills fractional weights with legacy arithmetic', () => { + expect(strategy.calculate(1234)).toBeCloseTo(123400, 6); + expect(strategy.calculate(723.4)).toBeCloseTo(72340, 6); + }); + + it('honours the configured base rate', () => { + expect(new WeightStrategy(12345).calculate(1000)).toBeCloseTo(123450, 6); + }); +}); diff --git a/src/modules/pricing/strategies/weight.strategy.ts b/src/modules/pricing/strategies/weight.strategy.ts new file mode 100644 index 0000000..b521a3e --- /dev/null +++ b/src/modules/pricing/strategies/weight.strategy.ts @@ -0,0 +1,11 @@ +/** Weight surcharge: free below 500 g, then cost per 100 g above. */ +export class WeightStrategy { + constructor(private readonly weightBaseRate: number) {} + + calculate(weightGr: number): number { + if (weightGr < 500) { + return 0; + } + return (weightGr / 100) * this.weightBaseRate; + } +} From 7f79a601d7ad1d89938217e3f788f1b2e9ec64dd Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Sat, 3 Oct 2026 13:13:47 +0330 Subject: [PATCH 3/5] docs: make docs/ committable and canonical (remove /docs from .gitignore, drop root phase-1-foundation.md) --- .gitignore | 3 +- docs/BREAKING-CHANGES.md | 59 +++++++ docs/CODEBASE-AUDIT.md | 242 +++++++++++++++++++++++++ docs/CONVENTIONS.md | 79 +++++++++ docs/INDEX.md | 72 ++++++++ docs/REFACTORING-PLAN.md | 159 +++++++++++++++++ docs/TESTING-STRATEGY.md | 91 ++++++++++ docs/phase-1-foundation.md | 107 ++++++++++++ docs/phase-2-security-config.md | 220 +++++++++++++++++++++++ docs/phase-3-auth-session.md | 183 +++++++++++++++++++ docs/phase-4-core-business.md | 272 +++++++++++++++++++++++++++++ docs/phase-5-supporting-modules.md | 128 ++++++++++++++ docs/phase-6-integration-ci.md | 117 +++++++++++++ phase-1-foundation.md | 29 --- 14 files changed, 1730 insertions(+), 31 deletions(-) create mode 100644 docs/BREAKING-CHANGES.md create mode 100644 docs/CODEBASE-AUDIT.md create mode 100644 docs/CONVENTIONS.md create mode 100644 docs/INDEX.md create mode 100644 docs/REFACTORING-PLAN.md create mode 100644 docs/TESTING-STRATEGY.md create mode 100644 docs/phase-1-foundation.md create mode 100644 docs/phase-2-security-config.md create mode 100644 docs/phase-3-auth-session.md create mode 100644 docs/phase-4-core-business.md create mode 100644 docs/phase-5-supporting-modules.md create mode 100644 docs/phase-6-integration-ci.md delete mode 100644 phase-1-foundation.md diff --git a/.gitignore b/.gitignore index da5cff9..ecbd0fb 100644 --- a/.gitignore +++ b/.gitignore @@ -51,5 +51,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 diff --git a/docs/BREAKING-CHANGES.md b/docs/BREAKING-CHANGES.md new file mode 100644 index 0000000..b623fad --- /dev/null +++ b/docs/BREAKING-CHANGES.md @@ -0,0 +1,59 @@ +# Breaking Changes & Frontend Coordination Log + +> Living document. Every API-visible change made during the refactor is recorded here with before/after details so the frontend (hambaar-app/frontend) can be updated in sync. + +**Format:** each entry lists affected endpoints, the change, and required frontend action. + +--- + +## Phase 1 — Foundation (no API changes) + +Phase 1 touches tooling, tests, and internal helpers only. + +| # | Change | API-visible? | Frontend action | +|---|---|---|---| +| 1.1 | Removed `moment` dependency; `getDateDifference` (used by dashboard "age" strings) now uses `date-fns` calendar math | No (internal formatting; output format unchanged: `X سال و Y ماه`) | None | +| 1.2 | OTP `generateCode` range fix is **not** applied in Phase 1 (deferred to Phase 3 with its own entry) | — | — | + +--- + +## Phase 2 — Bootstrap, Config & Security (applied) + +| # | Change | API-visible? | Frontend action | +|---|---|---|---| +| 2.1 | CORS now requires explicit `CORS_ORIGINS` — requests from unlisted origins are blocked (default: all blocked) | Yes | Provide frontend origin(s) to backend env | +| 2.2 | Validation 400s: unknown body fields now rejected (`forbidNonWhitelisted`) | Yes | Do not send fields not in the DTOs | +| 2.3 | `API_PREFIX` (default `api`) — all routes now under `/api/…` | Yes | Update API base URL | +| 2.4 | Swagger disabled in production | No | (n/a) | +| 2.5 | Global rate limiter returns 429 beyond `THROTTLE_LIMIT` per `THROTTLE_TTL` | Yes | Respect 429 + `Retry-After` | + +--- + +## Pending (specified in phase docs, not yet applied) + +Each remaining phase doc contains its own detailed breaking-changes section; the consolidated frontend digest is finalized in Phase 6. Headline items already specified: +- **Phase 4 (4.1):** tracking endpoint drops/masks `sender.phoneNumber` — **pending** (Task 5 not started). +- **Phase 5 (5.1–5.2):** support verification 404 when status missing; upload size limit enforced. + +> Rule: entries move from "pending (specified)" to "applied" **with the phase that lands them**. + +--- + +## Phase 4 — Core Business (partial: Task 1 + Task 2, no API change) + +| # | Change | API-visible? | Frontend action | +|---|---|---|---| +| 4.0 | `TransactionRunner` + pricing engine/strategies refactor landed 2026-09-04 — pricing output byte-identical by golden parity, runner internal only | No | None | +| 4.1 | Tracking PII mask (`sender.phoneNumber`) — **not yet applied** | — (pending) | — | +| 4.2 | Pricing byte-identical guarantee — holds for the partial landing | No | None — report any drift as a bug | + +--- + +## Phase 3 — Auth, Session & User Domain (applied) + +| # | Change | API-visible? | Frontend action | +|---|---|---|---| +| 3.1 | OTP is now **6 digits** (was 5) and zero-padded; `code` in `POST /auth/check-otp` is now a 6-digit **string** (was a 5-digit number) | Yes | Update SMS/template expectations; OTP input UI must accept 6 digits; send `code` as string | +| 3.2 | `JWT_*_EXPIRES_IN` envs now configurable — defaults unchanged (`20d/20m/1d`) | No (only if envs changed) | none | +| 3.3 | Session `userState` write for vehicle registration happens inside the service (same end state) | No (behavior parity) | none | +| 3.4 | Guard files re-exports only — no API change | No | none | diff --git a/docs/CODEBASE-AUDIT.md b/docs/CODEBASE-AUDIT.md new file mode 100644 index 0000000..1a799a1 --- /dev/null +++ b/docs/CODEBASE-AUDIT.md @@ -0,0 +1,242 @@ +# Hambaar Backend — Codebase Audit + +> **Date:** 2026-09-02 · **Baseline commit:** `ae11d31` (master) · **Scope:** full `src/`, `prisma/`, CI, Docker, tooling +> +> This document is the evidence-based foundation of the refactoring plan (`REFACTORING-PLAN.md`). +> Severity legend: 🔴 Critical · 🟠 High · 🟡 Medium · 🔵 Low / hygiene + +--- + +## 1. Project Snapshot + +| Item | Value | +|---|---| +| Framework | NestJS 11, Express 5, TypeScript 5.7 | +| ORM / DB | Prisma 6 + PostgreSQL (`prisma db push`, **no migration history**) | +| Cache / Session | Redis 5.x (`cache-manager` + `@keyv/redis`, `express-session` + `connect-redis`) | +| Storage | AWS S3 (pre-signed URLs) | +| External APIs | Neshan Maps, SMS (s.api.ir) | +| Source size | ~153 TS files, 17 feature modules | +| Tests | ~40 `*.spec.ts` files (shallow), 1 trivial e2e spec | + +**Module inventory:** app, auth, user, vehicle, token, s3, sms, address, package, trip, map, financial, support, notification, dashboard, pricing, turf, health, prisma. + +--- + +## 2. Architecture Issues + +### A-1 🔴 God services (business logic monoliths) +| File | Size | Injected deps | Problems | +|---|---|---|---| +| `src/modules/trip/trip.service.ts` | **32.6 KB** | 6 | CRUD + request lifecycle + tracking + rating + financial escrow + S3 + geo + notifications in one class | +| `src/modules/package/package.service.ts` | **22.2 KB** | 8 | Recipients, items, pricing, matching orchestration, requests, cancellation, tracking — one class | +| `src/modules/auth/auth.service.ts` | **14.9 KB** | 8 | OTP, signup, documents, session state machine — one class | + +Controllers receive raw Prisma models; services contain business rules, persistence queries, external-API calls, session mutation, and notification side effects simultaneously. + +### A-2 🔴 Layering violations +- Controllers receive raw Prisma entities (no response mapping consistently; `instanceToPlain`/`plainToInstance` used ad-hoc). +- `MapService` (infrastructure client) depends on `PrismaService` and performs DB lookups (`getIntermediateCitiesWithIds`) — infra layer reaching into the data layer. +- `SerializeInterceptor` exists (`src/common/serialize.interceptor.ts`) but is used in only one place (`health.controller.ts`); the rest of the API returns unfiltered entity objects (internal-field exposure risk). + +### A-3 🟠 Repeated transaction/error boilerplate +Every service repeats: +```ts +this.prisma.$transaction(async tx => { ... }) + .catch((error: Error) => { formatPrismaError(error); throw error; }); +``` +and every `find*OrThrow` call wraps the same `.catch(formatPrismaError)` — dozens of duplicated blocks. `formatPrismaError` is declared to return `never` and always throws, yet every caller still does `throw error` after it (unreachable code). + +### A-4 🟠 Session-as-database anti-pattern +Matching results, packages-in-progress, and user state are stored in **express-session** (`session.packages[].matchResults[]`, `session.userState`) — e.g. `matching.service.ts`, `package.service.ts#cancelRequest`, `auth.service.ts#getUserState`. Session mutations are not transactional with DB writes; `cancelRequest` mutates the session inside a Prisma transaction (Redis store is outside the tx — inconsistency risk). + +--- + +## 3. Security Issues + +### S-1 🔴 CORS reflects any origin with credentials +`src/main.ts:16-19`: `app.enableCors({ origin: true, credentials: true })` — mirrors the request origin; combined with cookie-based auth (`AccessToken`, `SessionId` cookies) any website can make credentialed requests. + +### S-2 🔴 Session cookie `secure: false` in all environments +`src/modules/app/app.module.ts:94` — with an explicit `// TODO: Fix this when you deploy on a https server`. + +### S-3 🟠 Access token lifetime: 20 days, no refresh/rotation +`src/modules/token/token.service.ts:27` — `expiresIn: '20d'`, no rotation mechanism, no server-side revocation. + +### S-4 🟠 No per-IP rate limiting +Only OTP-level attempt throttling exists (`AuthService.checkIfBlocked`). `sendOtp` and other SMS-triggering endpoints have no `ThrottlerGuard`/IP limits — SMS-bombing risk with direct cost. + +### S-5 🟠 No security headers; Swagger unauthenticated in prod +No `helmet`/CSP; Swagger UI exposed at `/docs` in production. + +### S-6 🟡 OTP entropy bias +`src/common/utilities.ts:20` — `crypto.randomInt(11_111, 99_999)`: 5-digit numeric OTP where the first digit can never be 0 or 1 (~11% of the code space excluded) and codes are not zero-padded to a fixed width. + +### S-7 🟡 `generateUniqueCode` weakness +`src/common/utilities.ts:24` — `Date.now().toString() + crypto.randomInt(1_111_111, 9_999_999)` — not guaranteed unique (no uniqueness check demonstrated), biased numeric range. + +### S-8 🟡 Tracking endpoint leaks sender phone number +`package.service.ts#getTrackingByCode` returns `sender.phoneNumber` to anyone holding a tracking code (route has no auth guard) — PII exposure. + +--- + +## 4. Configuration & Bootstrap Issues + +### C-1 🔴 Env config bug: trailing space in key +`src/modules/auth/auth.service.ts:50`: +```ts +this.maxSendAttempts = config.get('MAX_SEND_ATTEMPTS ', 5); +``` +The env key `'MAX_SEND_ATTEMPTS '` has a **trailing space** — the real env var is never read; the default (5) silently applies. There is **no config validation schema**, so this class of bug is undetectable at boot. + +### C-2 🔴 Redis client created inside `AppModule.configure()` +`src/modules/app/app.module.ts:74-99`: +- `configure()` is declared `async` but Nest does not await it — the session middleware may attach **after** the app starts listening (race). +- Bootstrap serializes on Redis availability with no error handling/retry. +- Session Redis client lifecycle is unmanaged (no shutdown/`client.quit()`). + +### C-3 🟠 Fragile positional cache store access +`auth.service.ts:47`: `this.cacheManager = cacheManager.stores[1];` — silently breaks if the store order in `app.module.ts` changes; no named-store abstraction. + +### C-4 🟠 Missing env validation + config drift +`ConfigModule.forRoot` has no `validationSchema`; every `config.get(..., default)` silently masks missing/typo'd variables (see C-1). Pricing defaults in code **differ from** README docs (e.g. `PRICING_WEIGHT_BASE_RATE` default 10000 vs README 8000; `PRICING_PLATFORM_COMMISSION` env var documented but unused — commission derived from `driverShare`). + +### C-5 🟡 Bootstrap hygiene (`src/main.ts`) +- No global prefix / versioning (`/api/v1`). +- No graceful shutdown (`app.enableShutdownHooks()` absent). +- `bootstrap()` not awaited / no top-level catch → unhandled rejection on startup failure. +- No compression; PORT not validated. + +### C-6 🟡 Health check depends on an external website +`health.controller.ts:37` — `http.pingCheck('nestjs-docs', 'https://docs.nestjs.com')`: production readiness depends on nestjs.com and generates outbound traffic per poll; memory heap threshold hardcoded. + +### C-7 🔵 Prisma managed by `db push`; `prisma/.env` committed +`prisma db push` runs on every `start:dev` and on every container start (`entrypoint.sh`); no migration history; `prisma/.env` (with DB URL) is committed. + +--- + +## 5. Code Smells & Correctness Risks + +### B-1 🔴 Private method accessed via bracket notation +`auth.service.ts:371`: `this.tokenService['generateAccessToken'](payload)` — TypeScript visibility deliberately bypassed; hides the real public API of `TokenService`. + +### B-2 🟠 `request.user` mutation order bug risk +Guards assign `request.user = { id: payload.sub, ...request.user }` (`token.guard.ts:50-53, 79-82`) — the spread **after** means an existing `request.user.id` (if any) overwrites the verified `payload.sub`. `CurrentUser` decorator reads `request.user?.[key]` with a string key — no type safety. + +### B-3 🟠 Duplicated guard logic +`TemporaryTokenGuard`, `ProgressTokenGuard`, `AccessTokenGuard`, `MultiTokenGuard`, `DenyAuthorizedGuard` share ~80% of their logic (cookie extract → verify → compare to session → set user) with divergent error messages and subtly different payload checks. + +### B-4 🟡 Silent failure swallowing + console logging +`matching.service.ts:55-58` — `analyzeTrip(...).catch(error => { console.error(...); return null; })` inside `Promise.allSettled`; `console.error/warn` used across `map.service.ts`, `sms.service.ts`, `utilities.ts`. No Nest `Logger` anywhere, no structured logging, no request correlation IDs. + +### B-5 🟡 Commented-out / dead code +`sms.service.ts:46-50` (commented OTP message), `token.service.ts:26,31,36` (pointless local aliases), commented `secure` cookie line, `// TODO` markers (payment gateway, departure-time filter, waypoint sorting, S3 upload limit). + +### B-6 🟡 Mixed date libraries +Both `moment` (legacy) and `date-fns` are dependencies; `moment` is used only in `utilities.ts#getDateDifference` — replaceable with `date-fns` (also calendar-inaccurate `asMonths() % 12` math). + +### B-7 🟡 Dependency hygiene +- `jest-mock-extended` is a **production dependency** (belongs in devDependencies). +- `cookie-parser` middleware is applied **after** the session middleware (`app.module.ts:101-104`) — parser should run before anything that reads cookies. +- `prisma/.env` committed to the repository. + +### B-11 🔴 (found during Phase 1) No ESLint configuration at all +The repo has **no ESLint config file** (no `eslint.config.*` / `.eslintrc.*`), so `npm run lint` fails — linting has never been enforced despite the CI narrative. Fixed in Phase 1 with a type-checked flat config. + +### B-12 🟠 (found during Phase 1) Test suite runtime & teardown leaks +Baseline `npm run test:cov` took **514 s** on a dev machine with worker force-exit warnings ("worker process has failed to exit gracefully") — ts-jest full type-checking per file plus leaked handles. Fixed in Phase 1 via `isolatedModules` transform (28 s) — type safety is enforced by `tsc --noEmit` and the new ESLint type-checked config instead. + +### B-8 🟡 Untyped helpers +`toPersianDigits(number)` has no type annotation; `getDateDifference` approximates calendar math; `utilities.ts` mixes concerns (crypto codes, Prisma error mapping, validators, formatting) in one 7.3 KB file. + +### B-9 🔵 Internal Prisma runtime import +`package.service.ts:16` — `import { JsonArray } from '../../../generated/prisma/runtime/library'` — internal path, breaks across Prisma versions. + +### B-10 🔵 Pricing tier arithmetic fragile +`pricing.service.ts:141` — `tier.maxKm - tier.minKm + (tier.minKm ? 1 : 0)` correct only for the current 1-based inclusive bounds; silently wrong if tier bounds change. + +--- + +## 6. Testing Issues + +### T-1 🔴 Coverage unmeasured and unenforced +`package.json#jest`: `collectCoverageFrom: ["**/*.(t|j)s"]` with `rootDir: "."` — coverage sweeps the entire repo; **no `coverageThreshold` at all**; CI runs `npm test` without coverage. + +### T-2 🔴 Controller tests assert nothing meaningful +Every `*.controller.spec.ts` mocks the service with `jest-mock-extended` and verifies that a method that just delegates returns its mock verbatim. Guards are stubbed with `canActivate: () => true`. These tests verify wiring, not behavior. + +### T-3 🟠 No tests for the highest-risk logic +Zero unit tests for: all guards, `token.service.ts`, `utilities.ts` (`formatPrismaError` — the app-wide error mapping!), validators (`IsValidS3Key`, datetime tuples), `serialize.interceptor.ts`, `matching.service.ts` scoring, `map.service.ts` parsing, `s3.service.ts`, `sms.service.ts`. + +### T-4 🟠 Inconsistent test styles +Some specs use `Test.createTestingModule` + `DeepMockProxy`, others hand-rolled `jest.fn()` objects (`auth.service.spec.ts` builds a manual `keyvMock`); no fixture builders; magic mock data inline. + +### T-5 🟡 e2e suite is the scaffold default +`test/app.e2e-spec.ts` asserts `getHello()` returns "Hello World!" — dead weight. + +### T-6 🟡 CI unit tests run with all prod secrets set +`test-build.yml` exposes AWS/JWT/session secrets to the unit-test job — masks accidental secret/network usage and violates least privilege. + +--- + +## 7. Observability & Ops + +- **O-1 🟠** No structured logger: `console.*` everywhere; no request-ID/correlation; Nest `Logger` unused. +- **O-2 🟡** No metrics/tracing; health endpoint only. +- **O-3 🟡** `Dockerfile`/`entrypoint.sh` run `prisma db push` + seeds on **every container start** (prod image mutates DB schema at boot, seeds via `concurrently`). +- **O-4 🔵** `.dockerignore` present (good); `dist/` exists locally (verify `.gitignore`). + +--- + +## 8. Positive Findings (keep) + +- Clean NestJS module folder boundaries; global `ValidationPipe` with `whitelist`/`transform`. +- `formatPrismaError` centralizes Prisma → HTTP error mapping (right concept, wrong ergonomics). +- OTP rate-limiting state machine (send/check attempts, block windows) is a thoughtful feature. +- Pre-signed URL upload flow with S3-key pattern validation (`IsValidS3Key`). +- `OwnershipGuard` with Reflector metadata; Turf.js corridor matching is a solid domain concept. +- Redis-backed sessions in prod; Docker + CI pipeline exists. + +--- + +## 9. Baseline Metrics + +*Filled after the Phase 1 baseline coverage run (see `TESTING-STRATEGY.md` for how it is reproduced).* + +| Metric | Baseline (2026-09-02) | Phase 6 Target | +|---|---|---| +| Statements % | 68.13 | 100% | +| Functions % | 41.80 | 100% | +| Lines % | 67.48 | 100% | +| Branches % | 59.82 | ≥ 95% | +| CI coverage gate | none | enforced | +| ESLint strict type-checked | no (added in Phase 1) | yes (0 errors) | + +--- + +## 10. Issue → Phase Mapping + +| Issue(s) | Phase | +|---|---| +| T-1…T-6, B-11, B-12 | Phase 1 | +| C-1…C-7, S-1…S-5, B-5, B-7 (cookie-parser order), O-1, O-3 | Phase 2 | +| A-1 (auth), A-3, A-4, B-1, B-2, B-3, S-6, S-7 | Phase 3 | +| A-1 (package/trip), A-2, A-3, A-4, B-4, B-9, B-10, S-8 | Phase 4 | +| remaining modules, infra adapters (S3/SMS/Maps) | Phase 5 | +| O-2, integration tests, final gates | Phase 6 | + +--- + +## 11. Decision Log (from product owner) + +| Decision | Choice | +|---|---| +| Backward compatibility | Mostly compatible; documented breaking changes allowed (frontend can be updated) | +| Database | Keep `prisma db push` and current schema; **application code only** | +| Refactor depth | Full professional restructure (domain layer, thin controllers, infra adapters, strategies) | +| Coverage policy | 100% statements/functions/lines, ≥95% branches, enforced by Jest thresholds + CI | +| Test types | Unit tests (fully mocked) + integration tests on real Dockerized Postgres/Redis in CI | + + + + diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md new file mode 100644 index 0000000..88e7214 --- /dev/null +++ b/docs/CONVENTIONS.md @@ -0,0 +1,79 @@ +# Conventions — Target Architecture & Coding Rules + +> Applies to all refactoring phases. New code must follow this document; touched code is migrated to it. + +## 1. Folder & Naming + +| Kind | Convention | Example | +|---|---|---| +| Folders | `kebab-case` | `trip-request.service.ts` location: `modules/trip/domain/` | +| Classes | `PascalCase`, suffixed by role | `TripRequestService`, `S3StorageAdapter` | +| DTOs | `*Dto` in `dto/` of the owning module | `CreateTripDto` | +| Interfaces/tokens | `*Port` for infra interfaces, injection token `'PORTS.SMS'` | `SmsPort` | +| Test files | `.spec.ts` co-located with source | `token.service.spec.ts` | +| Fixtures | `test/fixtures/*.fixture.ts` (shared, pure factories) | `user.fixture.ts` | +| Enums | single-purpose enum files under `common/enums/` | `cookies.enum.ts` | + +**Module layout (business modules with real logic):** +``` +/ +├── .module.ts +├── .controller.ts # thin HTTP only +├── application/ # orchestration services +├── domain/ # business rules / state machines +├── dto/ +└── types/ +``` + +## 2. Layering Rules + +1. **Controllers** — no injected `PrismaService`, no business rules, no S3/SMS/HTTP calls. Read params/DTO → call one application service → return mapped DTO. +2. **Application services** — own use-cases; may open transactions through `TransactionRunner`; compose domain services; never import Prisma types into their public signatures (use domain types). +3. **Domain services** — pure logic (state machines, pricing, scoring). They receive plain inputs; no `ConfigService` where a value can be constructor-injected as a plain option. +4. **Infra adapters (`src/infra/**`)** — the only place touching S3, SMS HTTP APIs, Neshan, raw Redis clients. Each implements an interface (`*Port`) and is bound via an injection token, so unit tests replace it with a fake. +5. **No cross-module imports of internals** — modules expose what others need via their module exports (services/tokens), never deep-import `application/domain` files of another module. +6. **Prisma client access** — only through `PrismaService` or `TransactionRunner`; `PrismaTransaction` type alias is the single transaction handle type. + +## 3. Error Handling + +- A single global `AllExceptionsFilter` maps exceptions to the standard error envelope; an internal + `PrismaErrorMapper` converts Prisma errors — services stop wrapping every call in `.catch(formatPrismaError)`. +- `formatPrismaError` is retired in favor of the filter; the mapping table (P2002 → 409, P2025 → 404, …) is preserved and unit-tested via the filter. +- Services throw typed Nest exceptions or domain exceptions; no `console.error` for control flow. +- Error response envelope (single shape for all errors): + +```json +{ + "statusCode": 409, + "message": "A Vehicle with the plate already exists.", + "error": "Conflict" +} +``` + +## 4. Validation & Serialization + +- All request bodies/params/queries validated by DTOs (`class-validator`) through the global `ValidationPipe` with `whitelist`, `transform`, `forbidNonWhitelisted`. +- Responses exposed through `@Serialize(Dto)` with `@Expose()`-annotated DTOs — never raw entities. +- Config: every env var declared in a validated schema (`src/common/config/env.validation.ts`); access via typed `ConfigService` getters or injected option objects — **no stringly-typed keys scattered in services**. + +## 5. Testing Conventions + +- One spec per production file; arrange-act-assert; `describe` per method. +- Mocking: `jest-mock-extended` for class dependencies; hand-written fakes for infra ports. +- Fixtures from `test/fixtures/` — no inline 20-line mock literals. +- No test requires env vars, DB, Redis, or network. Time-dependent tests use `jest.useFakeTimers()` / injected clock. +- Naming: `it('should when ')`. + +## 6. TypeScript & Lint + +- `strict: true` (tsconfig), `noUncheckedIndexedAccess` aspirational for new files. +- ESLint flat config with `typescript-eslint` recommended-type-checked; rules enabled in Phase 1: + `@typescript-eslint/no-explicit-any` (warn→error per phase), `no-floating-promises`, `no-misused-promises`, `no-unsafe-argument`, `no-unused-vars` (error), `eqeqeq`. +- Prettier: existing `.prettierrc` (singleQuote, trailingComma all) + `printWidth: 100`. +- No bracket-notation access to private members; no `as any` without a comment and a `// eslint-disable` line. + +## 7. Git & CI + +- Conventional Commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`) — one phase ≈ one PR series. +- CI stages: lint → unit tests (no secrets) → coverage gate → build (on master). Integration stage added in Phase 6. +- `prisma/.env` removed from the repo; `.env.development` remains as the documented template. diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..95644b1 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,72 @@ +# Hambaar Backend — Documentation Index + +> Complete documentation set for the **"Professional Refactor"** (started 2026-09-02). +> All files live in `docs/` and are updated as phases progress. + +## How to read this set + +`REFACTORING-PLAN.md` is the **master plan** (goals, phases, decisions, risks). +`CODEBASE-AUDIT.md` is the **evidence** (every issue, with severity and file refs). +`CONVENTIONS.md` is the **law** (target architecture, coding rules) for all new code. +`TESTING-STRATEGY.md` is the **coverage & test policy** used to grade every phase. +`BREAKING-CHANGES.md` is the **frontend coordination log** (every API-visible change). +One `phase-N-*.md` file per phase — the **implementation spec + as-built record**. + +## Document map + +| Doc | What it answers | Status | +|---|---|---| +| [REFACTORING-PLAN.md](./REFACTORING-PLAN.md) | What are we doing, in what order, and why | ✅ | +| [CODEBASE-AUDIT.md](./CODEBASE-AUDIT.md) | What is wrong right now (evidence) | ✅ | +| [CONVENTIONS.md](./CONVENTIONS.md) | How should the target code look | ✅ | +| [TESTING-STRATEGY.md](./TESTING-STRATEGY.md) | How do we prove quality (coverage policy) | ✅ | +| [BREAKING-CHANGES.md](./BREAKING-CHANGES.md) | What will the frontend need to change | ✅ | +| [phase-1-foundation.md](./phase-1-foundation.md) | Phase 1 spec + as-built | ✅ Completed | +| [phase-2-security-config.md](./phase-2-security-config.md) | Phase 2 spec + as-built | ✅ Completed | +| [phase-3-auth-session.md](./phase-3-auth-session.md) | Phase 3 spec + as-built | ✅ Completed | +| [phase-4-core-business.md](./phase-4-core-business.md) | Phase 4 spec + partial as-built + **Phases 1–4 rollup (§10)** | 🚧 In progress (Task 1 + Task 2 landed) | +| [phase-5-supporting-modules.md](./phase-5-supporting-modules.md) | Phase 5 spec (modules + infra adapters) | ✅ Spec complete | +| [phase-6-integration-ci.md](./phase-6-integration-ci.md) | Phase 6 spec (integration, CI, final gates) | ✅ Spec complete | + +## Quick stats + +| Metric | Value | +|---|---| +| Total audit issues | ~60 (🔴 12 · 🟠 20 · 🟡 20 · 🔵 8) | +| Phases | 6 | +| Docs in set | 12 | +| Coverage baseline (scoped) | 68 / 60 / 42 / 67 (Stmts/Branches/Funcs/Lines) | +| Coverage target | 100 / 95 / 100 / 100 | +| Suite runtime | 514 s → ~20 s | + +## Phase dependency graph + +``` +Phase 1 (foundation/tooling) ──► Phase 2 (bootstrap/config/security) + │ + ▼ + Phase 3 (auth/session/user) + │ + ▼ + Phase 4 (package/trip/pricing/matching) + │ + ▼ + Phase 5 (supporting modules + infra adapters) + │ + ▼ + Phase 6 (integration tests, CI gates, final docs) +``` + +Rationale: infrastructure first (everything depends on bootstrap/config), then auth (everything depends on auth), then the two god services, then the long tail, then locking it all in with integration + CI. + +## Changelog + +| Date | Change | +|---|---| +| 2026-09-02 | Full doc set created (audit, plan, conventions, testing, breaking-changes, index, all 6 phase docs). Phase 1 as-built filled. Baseline metrics back-filled from scoped coverage run. | +| 2026-09-03 | Phase 2 + Phase 3 landed (`cfb158a`, `ea84577`); Phase 3 as-built filled; BREAKING-CHANGES 2.x–3.x applied. | +| 2026-09-04 | Docs-only review: Phase 2 marked Completed; Phase 4 partial (runner + pricing) recorded with Phases 1–4 rollup in `phase-4-core-business.md` §10; root `phase-1-foundation.md` removed (docs/ is canonical); `/docs` removed from `.gitignore` (was untracked since `2c50a47`) so docs are committable. Verified: `tsc` clean, pricing+prisma 10 suites / 98 tests green, lint 17 errors / 236 warnings tracked as debt. | + +## Updating policy +- Any change to conventions/architecture goes through `REFACTORING-PLAN.md` and `CONVENTIONS.md`. +- Any API-visible change must add a `BREAKING-CHANGES.md` entry **before** it lands. \ No newline at end of file diff --git a/docs/REFACTORING-PLAN.md b/docs/REFACTORING-PLAN.md new file mode 100644 index 0000000..f42a497 --- /dev/null +++ b/docs/REFACTORING-PLAN.md @@ -0,0 +1,159 @@ +# Hambaar Backend — Refactoring Master Plan + +> **Status:** 🚧 In progress · **Started:** 2026-09-02 · **Baseline:** `ae11d31` +> +> See [INDEX.md](./INDEX.md) for the document map, [CODEBASE-AUDIT.md](./CODEBASE-AUDIT.md) for the full evidence base, [CONVENTIONS.md](./CONVENTIONS.md) for target architecture rules, [TESTING-STRATEGY.md](./TESTING-STRATEGY.md) for the coverage policy, and [BREAKING-CHANGES.md](./BREAKING-CHANGES.md) for API changes frontend must adopt. + +--- + +## 1. Goals + +1. **Professional architecture:** domain/business logic separated from persistence and infrastructure; thin controllers; explicit service boundaries; strategies for pricing & matching; global pipes/filters/interceptors instead of copy-pasted error handling. +2. **100% test confidence:** re-implement the entire unit test suite; enforce **100% statements / functions / lines, ≥95% branches** via Jest thresholds and the CI pipeline. +3. **Security & correctness:** fix the identified 🔴/🟠 issues (CORS, cookie flags, config validation, session bootstrap race, guard duplication, PII exposure). +4. **Zero schema changes:** Prisma schema and `db push` workflow stay untouched. +5. **Complete documentation:** every phase ships a phase doc; every breaking change is recorded for the frontend team. + +## 2. Non-Goals + +- Database schema redesign, migrations, or data changes. +- Changing deployment target (Liara + Docker Hub pipeline stays). +- New features (payment gateway etc. remain TODOs — documented, not implemented). + +## 3. Constraints & Ground Rules + +| Rule | Detail | +|---|---| +| App stays working | Every phase ends with a green build and a passing test suite; phases are independently shippable. | +| API stability | Existing endpoints keep their contracts unless listed in `BREAKING-CHANGES.md`. | +| No schema changes | Prisma schema files are read-only during the refactor. | +| Coverage gate | From Phase 1 onward, coverage is measured; the hard gate is enforced per-module as each phase completes (global gate switched on in Phase 6). | +| Test style | Unit tests are fully mocked (no DB/Redis/network); integration tests (Phase 6) use Dockerized Postgres/Redis in CI. | +| Secrets | Unit tests must never require env secrets or network access. | + +--- + +## 4. Phase Overview + +| # | Phase | Focus | Doc | +|---|---|---|---| +| 1 | Foundation & Test Infrastructure | Jest config, coverage baseline, test utilities, deps hygiene, docs | [phase-1-foundation.md](./phase-1-foundation.md) | +| 2 | Bootstrap, Config & Security | main.ts, config schema, session, guards consolidation, filters/interceptors | [phase-2-security-config.md](./phase-2-security-config.md) | +| 3 | Auth, Session & User Domain | OTP service, auth state machine, signup split, token API | [phase-3-auth-session.md](./phase-3-auth-session.md) | +| 4 | Core Business (Package/Trip/Matching/Pricing/Turf) | Split god services, pricing strategy, transaction runner | [phase-4-core-business.md](./phase-4-core-business.md) | +| 5 | Supporting Modules & Infra Adapters | vehicle, address, financial, map, s3, sms, support, dashboard, health | [phase-5-supporting-modules.md](./phase-5-supporting-modules.md) | +| 6 | Integration Tests, CI & Final Docs | Dockerized integration suite, CI gates, README, completion | [phase-6-integration-ci.md](./phase-6-integration-ci.md) | + +### Phase sequencing rationale +Phases are ordered by blast radius: infrastructure first (everything depends on bootstrap/config), then the auth stack (everything depends on auth), then the two god services, then the long tail. Tests are re-implemented **within the phase that touches the code**, so behavior is locked in as it is refactored. + +--- + +## 5. Target Architecture (end state) + +``` +src/ +├── main.ts # bootstrap only: pipes, filters, helmet, prefix, shutdown +├── app.module.ts # module composition only (no I/O in configure()) +├── common/ +│ ├── config/ # env schema validation + typed config namespaces +│ ├── constants/ +│ ├── decorators/ # CurrentUser, Serialize, @AuthRoles … +│ ├── enums/ +│ ├── exceptions/ # custom exception classes +│ ├── filters/ # AllExceptionsFilter, PrismaExceptionFilter +│ ├── guards/ # base token guard + composable auth guards +│ ├── interceptors/ # Serialize, Logging, Timeout +│ ├── pipes/ +│ ├── utils/ # small pure helpers (codes, digits, dates) — fully unit-tested +│ └── validators/ # class-validator custom constraints +├── infra/ # external world adapters (interfaces + impls) +│ ├── storage/ # S3StorageAdapter +│ ├── sms/ # SmsAdapter (s.api.ir) +│ ├── maps/ # MapsProvider (Neshan) +│ ├── cache/ # named Redis stores (cache, otp, session) +│ └── session/ # session middleware factory +└── modules/ + ├── auth/ + │ ├── application/ # AuthService (orchestration only) + │ ├── domain/ # OtpService, AuthStateMachine, signup policies + │ └── ...dto/controllers + ├── user/ + ├── vehicle/ + ├── address/ + ├── package/ + │ ├── application/ # PackageService (orchestration) + │ ├── domain/ # recipient/item/recipients rules, request lifecycle + │ └── matching/ # MatchingService + scoring strategies + ├── trip/ + │ ├── application/ + │ ├── domain/ # TripRequestService, TripTrackingService + │ └── ... + ├── pricing/ + │ ├── application/ + │ └── strategies/ # DistanceTierStrategy, SpecialHandlingStrategy, CityPremiumStrategy + ├── financial/ map/ notification/ dashboard/ support/ health/ token/ turf/ + └── prisma/ # PrismaService + TransactionRunner +``` + +**Layer rules:** +1. Controllers: HTTP concerns only — validate, delegate, serialize. No Prisma, no business logic. +2. Application services: orchestrate domain services + infra adapters; own transactions via `TransactionRunner`. +3. Domain services: pure business rules, unit-testable with zero mocks of Nest/Prisma internals where possible. +4. Infra adapters: the only code that talks to S3/SMS/Neshan/Redis; each behind an interface so unit tests swap in fakes. +5. No direct `cacheManager.stores[n]` positional access — named stores injected by token. +6. No `console.*` — Nest `Logger` (or a wrapper) only. + +--- + +## 6. Issue Disposition + +Every audited issue is either resolved by a phase or explicitly waived: + +| Issues | Disposition | Where | +|---|---|---| +| A-1, A-2 | Resolved — layer extraction + thin controllers | Phases 3–5 | +| A-3 | Resolved — `AllExceptionsFilter` + `PrismaErrorMapper` + `TransactionRunner` remove all boilerplate | Phase 2 (filter/mapper), Phase 4 (runner) | +| A-4 | Mitigated — session kept (no schema change allowed) but session mutation extracted into explicit domain functions with documented consistency caveats | Phases 3–4 | +| B-1..B-5, B-8..B-10 | Resolved in the phase owning the file | Phases 2–5 | +| B-6 | Resolved — `moment` removed, `getDateDifference` rewritten with `date-fns` | Phase 1 | +| B-7 | Partially resolved — `jest-mock-extended` moved to devDeps (Phase 1); cookie-parser-before-session ordering + `prisma/.env` removal remaining | Phase 1, Phase 2/6 | +| B-11 | Resolved — type-checked ESLint flat config added | Phase 1 | +| B-12 | Resolved — ts-jest `isolatedModules` (514s → ~20s); type safety via `tsc --noEmit` + ESLint | Phase 1 | +| C-1..C-7 | Resolved (C-7 partially: keep `db push`, remove committed `prisma/.env`, stop seeding on every boot) | Phase 2 | +| O-1, O-3 | Resolved | Phase 2 | +| O-2 | Waived — no schema/infra change budget; revisit post-refactor | — | +| S-1..S-8 | Resolved (S-3: token lifetime becomes configurable with sane default; rotation deferred — needs schema change) | Phases 2–4 | +| T-1..T-6 | Resolved — full test rewrite + CI gates | Phases 1, 6 | + +## 7. Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Hidden behavior depends on current bugs (e.g. trailing-space env key, biased OTP) | Medium | Medium | Each fix documented in phase doc + `BREAKING-CHANGES.md`; OTP bias fix is behavior-visible only in digit distribution | +| Session-based matching breaks after extraction | Medium | High | Characterization tests written **before** refactor (existing behavior locked in tests first) | +| Coverage gate blocks merge mid-refactor | Medium | Low | Thresholds enforced globally only at Phase 6; per-module expectations tracked in phase docs | +| Frontend breaks on standardized error shape | High | Medium | Error shape changed only in Phase 2 with exact before/after JSON in `BREAKING-CHANGES.md`; frontend coordination step included | +| Windows/CI dev-environment drift | Low | Low | All test commands runnable cross-platform; CI is the source of truth | + +## 8. Definition of Done (per phase) + +- [ ] All phase tasks implemented and self-reviewed +- [ ] New/changed code at 100% statements/functions/lines, ≥95% branches (module-level `coverageThreshold` where practical) +- [ ] `npm run lint` and `npm test` green locally and in CI +- [ ] Phase doc updated with "as-built" notes and any deviations +- [ ] `BREAKING-CHANGES.md` updated if any contract changed +- [ ] App boots and existing happy-path endpoints respond identically (smoke check) + +## 9. Progress Tracker + +| Phase | Status | Tests re-implemented | Coverage (module) | Notes | +|---|---|---|---|---| +| 1 — Foundation | ✅ Completed | — | baseline: 68.13/59.82/41.80/67.48 | suite 514s → ~20s; ESLint added; **all docs complete** ([phase-1-foundation.md](./phase-1-foundation.md)) | +| 2 — Security/Config | ✅ Completed | env.validation, cookie-options, bootstrap-options, prisma-error-mapper, all-exceptions.filter, transform/logging/timeout, guards (5), session provider, health | 100/100/100 (phase-2 paths ≥95 branches; lint debt tracked in Phase 4 §10.6 D-5) | `cfb158a` 40 files; BREAKING-CHANGES 2.1–2.5 applied ([phase-2-security-config.md](./phase-2-security-config.md)) | +| 3 — Auth/Session | ✅ Completed | codes, otp.service, auth-state.machine, session-utils, token.service, auth.service, transporter-signup.service, auth.controller, user.service | 100/100/100 (auth & token paths ≥95 branches) | OTP 6-digit + OtpService + OTP_CACHE; TokenService public API; AuthStateMachine + session-utils; slim AuthService + TransporterSignupService; S3StoragePort in user ([phase-3-auth-session.md](./phase-3-auth-session.md)) | +| 4 — Core Business | 🚧 In progress (Task 1 + Task 2) | transaction-runner, pricing engine + 6 strategies + factory + facade | pricing+prisma 10 suites / 98 tests green (2026-09-04); module 100/100/100/95 claimed in commits, lint 17 errors tracked | Runner + pricing landed (`3110252`, `7985842`); matching/trip/package/turf pending; **Phases 1–4 rollup in §10** ([phase-4-core-business.md](./phase-4-core-business.md)) | +| 5 — Supporting Modules | ⬜ Not started | — | — | spec complete ([phase-5-supporting-modules.md](./phase-5-supporting-modules.md)) | +| 6 — Integration & CI | ⬜ Not started | — | — | spec complete ([phase-6-integration-ci.md](./phase-6-integration-ci.md)) | + + diff --git a/docs/TESTING-STRATEGY.md b/docs/TESTING-STRATEGY.md new file mode 100644 index 0000000..83936e2 --- /dev/null +++ b/docs/TESTING-STRATEGY.md @@ -0,0 +1,91 @@ +# Testing Strategy + +> Target policy for the whole refactor. Phase 1 sets up the infrastructure; phases 2–5 re-implement tests module by module; Phase 6 adds integration tests and switches on the hard global gate. + +## 1. Coverage Policy + +| Metric | Target | Enforcement | +|---|---|---| +| Statements | 100% | Jest `coverageThreshold.global` | +| Lines | 100% | Jest `coverageThreshold.global` | +| Functions | 100% | Jest `coverageThreshold.global` | +| Branches | ≥ 95% | Jest `coverageThreshold.global` | +| CI gate | fail build below target | `.github/workflows/test-build.yml` (Phase 6) | + +**Scope** (`collectCoverageFrom`): `src/**/*.ts`, excluding `src/main.ts` (bootstrap, exercised via integration tests in Phase 6) and `*.module.ts` (declarative DI wiring). + +**Baseline (2026-09-02, scoped, commit `ae11d31` + Phase 1 tooling):** + +| Metric | Baseline | +|---|---| +| Statements | 68.13% | +| Branches | 59.82% | +| Functions | 41.80% | +| Lines | 67.48% | + +Updated after Phase 1 (raw baseline 6.99/1.8/3.51/25.28 was inflated by an incorrect `collectCoverageFrom` +sweeping the whole repo; the scoped numbers above are the real source-tree baseline). +Reproduce with: `npm run test:cov` (see §6). + +## 2. Test Types + +| Type | Location | Runner | Infra | +|---|---|---|---| +| Unit | `src/**/*.spec.ts` (co-located) | `npm test` | none — everything mocked | +| Integration (Phase 6) | `test/integration/*.spec.ts` | `npm run test:integration` | real Postgres + Redis (Docker Compose in CI) | +| E2E (Phase 6, selective) | `test/e2e/*.spec.ts` | `npm run test:e2e` | real Postgres + Redis + mocked externals | + +The old scaffold `test/app.e2e-spec.ts` ("Hello World!") is excluded from unit runs (fixed `testRegex`) and +removed in Phase 6, replaced by real e2e smoke specs. + +## 3. Unit Test Conventions + +### 3.1 Structure +- One spec file per production file, co-located: `token.service.ts` → `token.service.spec.ts`. +- `describe('')` → nested `describe('')` → `it('should when ')`. +- Arrange–Act–Assert; no test logic in constructors/hooks beyond wiring. + +### 3.2 Mocking standards +- Class dependencies: `mockDeep()` from `jest-mock-extended`. +- Infra ports (Phase 5+): hand-written in-file fakes implementing the port interface. +- Module-level mocks (`jest.mock(...)`) only for impure utilities (`generateCode`, crypto) — prefer injecting fakes. +- `clearMocks: true` is set globally — every test must re-stub what it needs (no cross-test stub leakage). + +### 3.3 Fixtures +- Shared factories live in `test/fixtures/*.fixture.ts` (`createMockUser`, `createMockSession`, `createLocation`, …). +- Factories return **fresh deep objects per call**; module-level exported constants are read-only conveniences. +- Never mutate shared constants; create your own instance: `createMockUser({ role: RolesEnum.admin })`. + +### 3.4 Determinism +- Time-dependent tests: `jest.useFakeTimers().setSystemTime(...)` or inject a clock (Phase 3 OTP tests). +- Randomness (`generateCode`, `generateUniqueCode`): spy on the crypto-backed utility or assert on range/format, not exact values. +- No test may hit the network, require env secrets, or require Postgres/Redis. CI unit job runs with **no secrets**. + +### 3.5 What "100%" means in practice +- Every public method happy path **and** every explicit error/throw path is asserted (exception type + message). +- Every branch that represents behavior (null checks, env fallbacks, status transitions) is exercised on both sides. +- Defensive-only branches (e.g. impossible states behind `instanceof` for third-party internals) may be excluded via `/* istanbul ignore next */` **with an explanatory comment** — target is ≥95% branches precisely to allow a small, documented residue. +- Controller tests must assert delegation **and** response mapping — not just "returns the mock". + +## 4. Module Test Plans + +Detailed per-phase checklists live in the phase docs. Coverage expectations per module are tracked in the master plan's progress tracker. + +## 5. CI + +- **Phase 1:** CI runs `npm test` (unit) — now **~20 s** instead of 514 s — plus `npm run lint` and `tsc --noEmit`. +- **Phase 6:** CI gains a coverage report artifact + `coverageThreshold` gate, then the integration job (Postgres/Redis services) running `prisma db push` + `npm run test:integration`. + +## 6. Commands + +```bash +npm test # unit tests (watch mode: npm run test:watch) +npm run test:cov # unit tests + coverage report (baseline reproduction) +npm run lint # type-checked ESLint (flat config) +npx tsc --noEmit # full type check (ts-jest runs isolated, type safety lives here) +``` + +## 7. Known Trade-offs + +- `ts-jest` runs with `isolatedModules: true` for speed (514 s → 28 s). Type errors in test files are caught by `tsc --noEmit` and ESLint's type-checked rules, which the CI also runs. +- `*.module.ts` and `main.ts` are excluded from coverage targets; their logic is validated by integration tests (Phase 6). diff --git a/docs/phase-1-foundation.md b/docs/phase-1-foundation.md new file mode 100644 index 0000000..f9df039 --- /dev/null +++ b/docs/phase-1-foundation.md @@ -0,0 +1,107 @@ +# Phase 1 — Foundation & Test Infrastructure + +> **Status:** ✅ Completed · **Doc updated:** 2026-09-02 +> +> Goal: make quality measurable and the toolchain trustworthy **before** any logic is refactored. No behavior-level API changes. + +## 1. Scope + +1. Fix the broken Jest/coverage configuration and measure a real baseline. +2. Fix broken/absent tooling (ESLint) and dependency hygiene. +3. Establish shared test fixtures for all later phases. +4. Ship the documentation set for the whole refactor. +5. Speed up the test suite (it was unusably slow). + +## 2. Tasks & As-Built Results + +### 2.1 Jest / coverage configuration ✅ +**Problem (audit T-1):** `collectCoverageFrom: ["**/*.(t|j)s"]` with `rootDir: "."` swept the entire repo (including `test/`, e2e, and everything else) and no threshold existed. + +**Changes (`package.json#jest`):** +- `collectCoverageFrom`: `src/**/*.ts` minus `main.ts` and `*.module.ts` (rationale in `TESTING-STRATEGY.md §1`). +- `coverageDirectory: ./coverage`, `coverageReporters: [text, text-summary, json-summary, lcov]` (json-summary enables per-module tracking later). +- `clearMocks: true` — no cross-test stub leakage. +- Removed `verbose: true` (noise, slower output). + +### 2.2 Test suite performance ✅ +**Problem (audit B-12):** full coverage run took **514 s** with jest worker force-exit warnings. + +**Change:** ts-jest transform now runs with `isolatedModules: true`. +**Result:** coverage run now **~20 s** (≈25× faster). Type safety is preserved by `tsc --noEmit` (CI green) + ESLint type-checked rules. + +### 2.3 Dependency hygiene ✅ +**Problem (audit B-6, B-7):** `moment` + `date-fns` both shipped; `jest-mock-extended` was a production dependency. + +**Changes:** +- `moment` **removed**; `getDateDifference` rewritten with `date-fns`' `differenceInCalendarMonths` (calendar-accurate, replaces the `asMonths() % 12` approximation). Output format unchanged. +- `toPersianDigits` gained a `(value: string | number)` signature and radix-safe parsing. +- `jest-mock-extended` moved to `devDependencies`. +- Lockfile re-synced (`npm install` clean). + +### 2.4 ESLint (new) ✅ +**Problem (audit B-11):** no ESLint config existed at all — `npm run lint` failed. + +**Change:** new `eslint.config.mjs` (flat config): +- `@eslint/js` recommended + `typescript-eslint` **recommendedTypeChecked** (projectService). +- Errors enforced from Phase 1: `no-unused-vars`, `no-floating-promises`, `no-misused-promises`, `no-empty`, `eqeqeq`, prettier. +- Warnings (fixed per-module in phases 2–5): `no-explicit-any`, `no-unsafe-*` family, `no-base-to-string`, `restrict-template-expressions`, `require-await` (removing redundant `async` changes signatures — done per module), `no-console`. +- Spec/test files: type-checked too (tsconfig now includes them); mock-heavy rules relaxed. +- **Result: 0 lint errors** (250 warnings tracked as the phases 2–5 backlog). + +**Code fixes applied to satisfy the new gates (all behavior-preserving):** +- `main.ts`: `void bootstrap();` (floating promise). +- `utilities.ts`: braced `case 'P2003'` block (lexical declaration in case). +- `user.service.ts`: unused `phoneNumber` param documented + `_`-prefixed (phone changes go through OTP flow). +- `create-vehicle.dto.ts`, `support.controller.ts`, `health.controller.ts`, `trip.controller.ts`, `trip.service.ts`, `add-funds.dto.ts`, `s3.dto.ts`, `turf.service.spec.ts`: dead imports removed. +- Guards (`deny-authorized`, `multi-token`, `ownership`, `redis-health.indicator`, `sms.service`, `trip.service`): `catch (error)` with unused binding → binding-less `catch` with explanatory comments (no behavior change). +- `auth.service.ts`: `Promise` → `Promise`. +- `auth/types/*`, `token/jwt-payload.d.ts`: dead type imports removed. +- `update-request.dto.ts`: local enum renamed to `UpdateRequestStatusEnum` with an explanatory note (validation subset is intentional — clients may only accept/reject). + +### 2.5 Shared test fixtures ✅ +New `test/fixtures/`: +- `user.fixture.ts` — `createMockUser(overrides)` (+ `mockUser`, `mockTransporterUser`). +- `session.fixture.ts` — `createMockSession(overrides)` matching the real `SessionData` augmentation (`userId`, `phoneNumber`, `userState`, `accessToken`, `packages`). +- `location.fixture.ts` — `createLocation` + `ORIGIN_LOCATION` / `DESTINATION_LOCATION` (string lat/lng, matching `Location`). +- `index.ts` barrel. + +Fixtures are used by phases 2–5; existing specs migrate to them as each module is touched. + +### 2.6 Documentation ✅ +Created `docs/` set: `CODEBASE-AUDIT.md`, `REFACTORING-PLAN.md`, `CONVENTIONS.md`, `TESTING-STRATEGY.md`, `BREAKING-CHANGES.md`, this file. + +### 2.7 Dead e2e scaffold ⏳ +`test/app.e2e-spec.ts` ("Hello World!") is excluded from unit runs by the fixed `testRegex`; it is removed in Phase 6 together with the new e2e suite (kept for now so `test/jest-e2e.json` stays meaningful). + +## 3. Baseline Metrics (scoped, after this phase's tooling fixes) + +| Metric | Baseline | Phase 6 Target | +|---|---|---| +| Statements | **68.13%** | 100% | +| Branches | **59.82%** | ≥ 95% | +| Functions | **41.80%** | 100% | +| Lines | **67.53%** | 100% | +| Suite runtime | 514 s → **~20 s** | — | +| Test suites | 28 passed / 205 tests | re-implemented by module phases | + +## 4. Validation Performed + +- `npm install` — clean. +- `npx tsc --noEmit` — exit 0 (no type regressions from the `utilities.ts` rewrite or fixtures). +- `npm run lint` — runs with the new type-checked flat config (results below). +- `npm run test:cov` — 28 suites / 205 tests, all passing, scoped coverage report produced. + +## 5. Deviations from the original plan + +- The global `coverageThreshold` block was **not** added yet — with a 41.8% function baseline it would fail CI immediately. Thresholds are enforced per-module as each phase completes and globally in Phase 6 (as recorded in `REFACTORING-PLAN.md §3`). +- Removal of the scaffold e2e spec deferred to Phase 6 (see 2.7). + +## 6. Checklist (Definition of Done) + +- [x] Jest config scoped + coverage reproducible +- [x] Baseline measured and recorded +- [x] ESLint flat config added, lint runs +- [x] `moment` removed, `jest-mock-extended` in devDependencies +- [x] Shared fixtures created +- [x] Docs set created +- [x] tsc + tests + lint validated diff --git a/docs/phase-2-security-config.md b/docs/phase-2-security-config.md new file mode 100644 index 0000000..acd1637 --- /dev/null +++ b/docs/phase-2-security-config.md @@ -0,0 +1,220 @@ +# Phase 2 — Bootstrap, Config & Security + +> **Status:** ✅ Completed (`cfb158a`, 2026-09-03) · **Spec version:** 1.0 (2026-09-02) +> +> **Audit issues in scope:** C-1…C-7, S-1…S-5, A-3 (filter part), B-2, B-3, B-5, B-7, O-1, O-3. +> **Docs:** [CONVENTIONS.md](./CONVENTIONS.md) (layering), [BREAKING-CHANGES.md](./BREAKING-CHANGES.md) (this phase adds its first real entries). + +## 1. Objective + +Make the application boot **professionally and securely** without touching business logic: +typed configuration that fails fast, safe-by-default HTTP settings, structured error handling +replacing the copy-pasted `.catch(formatPrismaError)` pattern, consolidated auth guards, +and a working rate limiter. + +## 2. Dependencies to add + +| Package | Why | +|---|---| +| `helmet` | HTTP security headers (S-5) | +| `compression` | gzip responses | +| `@nestjs/throttler` | per-IP rate limiting (S-4) | + +`npm i helmet compression @nestjs/throttler` + +--- + +## 3. Task 1 — Typed configuration (`src/common/config/`) + +### 3.1 Env validation schema +Create `src/common/config/env.validation.ts` using `class-validator` + `plainToInstance` via +`ConfigModule.forRoot({ validate })`. Schema covers **every** env the app reads: + +```ts +// Required +DATABASE_URL, REDIS_URL, OTP_REDIS_URL, SESSION_REDIS_URL, +SESSION_SECRET, COOKIE_SECRET, +JWT_ACCESS_SECRET_KEY, JWT_TEMP_SECRET_KEY, JWT_PROGRESS_SECRET_KEY, +AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_BUCKET_NAME, AWS_ENDPOINT, +MAP_API_KEY, MAP_API_URL, SMS_API_KEY +// Optional with validated types/range +PORT, COOKIE_MAX_AGE, OTP_EXPIRATION_TIME, MAX_SEND_ATTEMPTS, MAX_CHECK_ATTEMPTS, +SEND_WINDOW, BASE_BLOCK_TIME, PRICING_*, CORRIDOR_WIDTH, AWS_REGION, +CORS_ORIGINS, THROTTLE_TTL, THROTTLE_LIMIT, API_PREFIX, LOG_LEVEL +``` + +- **Strategy:** production-like environments (CI, prod) use strict mode (missing required → boot fails). +- **Dev:** `.env.development` supplies values; missing optional keys fall back to existing code defaults. +- **Action:** replace `ConfigModule.forRoot` in `app.module.ts` with `forRoot({ isGlobal: true, validate, envFilePath })`. +- **Fix C-1 permanently:** the schema surfaces the trailing-space key immediately; Phase 3 rewires `AuthService` to the canonical key. + +### 3.2 Typed config access +- Add `src/common/config/config-names.ts` exporting **const objects** for the stringly-typed keys + (`ConfigKey.Pricing.BasePrice`, `ConfigKey.Auth.MaxSendAttempts`, …) to eliminate typos. +- Migrate only **bootstrap/shared** consumers in Phase 2 (services are migrated in their own phase). + +### 3.3 Unit tests — `env.validation.spec.ts` +- valid full env → passes +- each required key missing → fails with a message naming the key +- invalid value types (e.g. `PORT=abc`) → fails +- JWT secrets too short (<16 chars) → fails (new security rule) + +--- + +## 4. Task 2 — `main.ts` bootstrap + +Rewrite `src/main.ts` to a single, testable function. + +### 4.1 New bootstrap behavior +| Concern | Change | +|---|---| +| Security headers | `app.use(helmet())` | +| Compression | `app.use(compression())` | +| CORS | `origin: parseCorsOrigins(config)` from `CORS_ORIGINS` (comma-separated; **never** `true`) — fixes **S-1** | +| Global prefix | `app.setGlobalPrefix(config.get('API_PREFIX', 'api'))` — env-controlled, see Breaking Changes | +| Validation | global `ValidationPipe` moved here (and to a provider) with `forbidNonWhitelisted` | +| Shutdown | `app.enableShutdownHooks()` — fixes **C-5** | +| Swagger | mounted only when `NODE_ENV !== 'production'` — fixes **S-5** | +| Logging | Nest `Logger` instead of `console.log` — fixes **O-1** | +| Startup errors | `void bootstrap().catch(...)` — top-level catch — fixes **C-5** | + +### 4.2 Cookie helpers +Create `src/common/cookies/cookie-options.ts` (`getCookieOptions(config)`): one place for +`httpOnly: true`, `secure: config.get('COOKIE_SECURE', NODE_ENV === 'production')`, `sameSite` +from env, `maxAge`. **Fixes S-2** and the duplicated cookie objects in `auth.controller.ts` +(two more sites; deep links listed in Phase 3). + +### 4.3 Tests +`main.ts` is excluded from unit coverage (integration-covered in Phase 6); extract pure helpers +(`parseCorsOrigins`, `buildSwaggerConfig`, `isSwaggerEnabled`) into `src/common/http/bootstrap-options.ts` +and unit-test them at 100%. + +--- + +## 5. Task 3 — Session & middleware lifecycle (`src/infra/session/`) + +Fix **C-2** and **B-7 (parser order)**: +1. `src/infra/session/session.constants.ts` — `SESSION_STORE_PROVIDER` token. +2. `src/infra/session/session.provider.ts` — an async factory that creates the Redis client, + returns a `{ client, store, middleware }`, and registers `onApplicationShutdown` → `client.quit()`. +3. New global `SessionModule` wires **cookie-parser first**, then session middleware via + `MiddlewareConsumer` — done **synchronously** (no async `configure()`). +4. `app.module.ts` drops its `configure()` Redis setup entirely — it becomes a pure composition root + (first half of O-3). + +Tests: `session.provider.spec.ts` (fake redis client: connection, store construction, quit-on-shutdown), +`session.module.spec.ts` (middleware order assertion). + +--- + +## 6. Task 4 — Consolidated auth guards (`src/common/guards/`) + +Fix **B-2** and **B-3**: +1. `token.guard.base.ts` — `abstract BaseTokenGuard`: + - cookie name provided by subclass + - `verify()` delegates to `TokenService` + - session phone/sub comparison policy provided by subclass + - **fixed** `request.user` merge: `{ ...request.user, id: payload.sub }` (verified id is never overridden) — **B-2** + - injectable `MissingToken` / `InvalidToken` messages +2. Refactor the five guards to extend it (same public class names, same behavior/messages). + - `DenyAuthorizedGuard` keeps its inverse semantics (throws `BadRequestException(AlreadyAuthorized)`). +3. Move guard files under `src/common/guards/`; the legacy paths + (`auth/guard/token.guard.ts`, `multi-token.guard.ts`, `deny-authorized.guard.ts`, `ownership.guard.ts`) + become re-exports until Phase 3 removes them. + +Tests — **first 100% guard coverage** (audit T-3): +- each of the 5 guards: missing cookie, invalid token, expired token, phone mismatch, phone/sub match + sets `request.user.id` correctly +- `DenyAuthorizedGuard`: valid session → 400 `AlreadyAuthorized`; no/invalid token → pass-through +- `OwnershipGuard`: no config → true; no user → 403; admin → true; missing `:id` param → 400; + un-owned resource → 403 `EntityAccessDenied`; invalid entity name → error; Prisma failure → 403 fallback + +--- + +## 7. Task 5 — Global filters, interceptor, error envelope + +### 7.1 `PrismaErrorMapper` (pure — replaces the mapping half of `formatPrismaError`) +`src/common/errors/prisma-error-mapper.ts` returns `{ status, message }` per Prisma code +(P2000/P2002/P2003/P2004/P2025/P2016/validation/unknown) using the **exact** messages the current +`utilities.ts#formatPrismaError` produces (behavior-preserving), plus a `default` 500 branch. +`formatPrismaError` stays as a thin wrapper (still used by services until Phase 4 refactors them away). + +### 7.2 `AllExceptionsFilter` +`src/common/filters/all-exceptions.filter.ts`: `HttpException` → existing envelope unchanged; +Prisma errors → `PrismaErrorMapper`; unknown → 500 with `requestId` (from `RequestIdMiddleware`) +and a log line. Response keeps `{ statusCode, message, error }`. + +### 7.3 `TransformInterceptor` (optional success envelope) +`src/common/interceptors/transform.interceptor.ts` wraps success into `{ data }` **only when** +`RESPONSE_WRAPPER_ENABLED=true` (default off → byte-compatible responses). Opt-in to avoid +breaking the frontend before coordination (entry recorded in `BREAKING-CHANGES.md`). + +### 7.4 `RequestIdMiddleware` + `LoggingInterceptor` +Assign `req.id` (from `crypto.randomUUID()` or incoming `X-Request-Id`), log method/path/status/duration +via Nest `Logger`. Fixes **O-1**. + +### 7.5 `TimeoutInterceptor` +`TIMEOUT_MS` env (default 30s) → `TimeoutException` (504). + +Tests: `prisma-error-mapper.spec.ts` (every code + odd `meta` shapes), `all-exceptions.filter.spec.ts` +(every exception type, requestId present, prod/dev log behavior), `transform.interceptor.spec.ts` +(enabled/disabled, arrays, null), logging + timeout specs. + +--- + +## 8. Task 6 — Rate limiting (`@nestjs/throttler`) + +- `ThrottlerModule.forRootAsync` with `THROTTLE_TTL` / `THROTTLE_LIMIT` (env; sane defaults). +- Applied globally; OTP endpoints keep their domain-level throttling in `AuthService` + (Phase 3) — the global limiter protects the remaining endpoints (S-4). + +## 9. Task 7 — Health endpoint hardening (C-6) + +- Remove the `nestjs-docs` external `pingCheck` (unnecessary outbound traffic per poll). +- `MEMORY_HEAP_THRESHOLD_MB` env for the heap check. +- Keep DB + Redis indicators (each points at real infra). + +Test: `health.controller.spec.ts` re-implemented (stubbed `HealthCheckService`). + +--- + +## 10. Breaking changes (this phase) + +| # | Change | Frontend action | +|---|---|---| +| 2.1 | CORS now requires explicit `CORS_ORIGINS` — requests from unlisted origins are blocked | Provide frontend origin(s) to backend env | +| 2.2 | Validation 400s: unknown body fields now rejected (`forbidNonWhitelisted`) | Do not send fields not in the DTOs | +| 2.3 | `API_PREFIX` (default `api`) — all routes move under `/api/…` | Update API base URL (coordinate on the value) | +| 2.4 | Swagger disabled in production | (n/a unless docs are needed in prod) | +| 2.5 | Rate limiter returns 429 beyond `THROTTLE_LIMIT` per `THROTTLE_TTL` | Respect 429 + `Retry-After` | + +> 2.2 and 2.3 land only **after** frontend coordination (documented decision log). Everything else is safe-by-default. + +--- + +## 11. Definition of Done (checklist) + +- [x] Config schema active in `app.module`; CI/prod boot fails on missing required env vars +- [x] `main.ts` hardened (helmet, compression, CORS env, prefix, shutdown hooks, swagger gate, logger) +- [x] Session provider replaces async `configure()`; cookie-parser registered before session +- [x] Guards consolidated with zero message/behavior change; all guard specs at 100% +- [x] `AllExceptionsFilter` + `PrismaErrorMapper` at 100%; validation errors standardized +- [x] Throttler active +- [x] Health external ping removed +- [x] `tsc` exit 0, `npm test` green; Phase-2-touched modules 100/100/100/95 — **debt:** `npm run lint` reports 16 unused-import/floating-promise errors in Phase-2 specs/filter (see Phase 4 §10.6 D-5), tracked for cleanup +- [x] `BREAKING-CHANGES.md` updated (2.1–2.5); phase doc as-built filled in + +--- + +## 12. As-built notes + +**Status:** ✅ Completed 2026-09-03 (`cfb158a`, 40 files, +2320/−411). + +Landed as specified: `EnvSchema` + `ConfigKey`, `getCookieOptions`, bootstrap helpers, +`PrismaErrorMapper` + `AllExceptionsFilter`, `Transform`/`Logging`/`Timeout`, +`RequestIdMiddleware`, `infra/session/`, `BaseTokenGuard` + 5 guards, hardened `main.ts` ++ pure composition-root `app.module.ts`, health ping removal, `helmet`/`compression`/`throttler` deps. + +Deviations: none behavior-visible. Lint debt (unused imports in new specs/filter, +`health.controller.spec.ts` floating promise) recorded in Phase 4 §10.6 D-5 and fixed +with the owning phase — docs-only review 2026-09-04 made no `src/` changes. \ No newline at end of file diff --git a/docs/phase-3-auth-session.md b/docs/phase-3-auth-session.md new file mode 100644 index 0000000..812d941 --- /dev/null +++ b/docs/phase-3-auth-session.md @@ -0,0 +1,183 @@ +# Phase 3 — Auth, Session & User Domain + +> **Status:** ✅ Completed · **Spec version:** 1.0 (2026-09-02) +> +> **Audit issues in scope:** A-1 (auth god service), A-3 (error boilerplate removal in auth), A-4 (session mutation), B-1 (`tokenService['generateAccessToken']`), B-2 (guard merge fix — completed in Phase 2), B-3 leftovers, S-6 (OTP entropy), S-7 (`generateUniqueCode`), C-1 (trailing-space key). +> **Depends on:** Phase 2 (config, guards, filters). + +## 1. Objective + +Turn the 15 KB `auth.service.ts` (8 injected deps) into a **thin orchestration service + pure domain +services**, fix the token encapsulation violation, build an explicit auth state machine, and make +OTP handling deterministic and testable. Rewrite the auth/user/token test suites to 100%. + +## 2. Target module layout + +``` +src/modules/auth/ +├── auth.module.ts +├── auth.controller.ts # thin: body → service → cookies via cookie-options helper +├── application/ +│ ├── auth.service.ts # slim orchestrator (sendOtp, checkOtp, signup*, submitDocuments, getUserState) +│ └── transporter-signup.service.ts +├── domain/ +│ ├── otp.service.ts # OTP lifecycle + rate limiting state machine +│ ├── auth-state.machine.ts # computeTransporterState + transitions +│ └── session-utils.ts # pure session mutation helpers +├── dto/ guards/ types/ # as today (guards re-export from src/common/guards) +``` + +--- + +## 3. Task 1 — `OtpService` (`src/modules/auth/domain/otp.service.ts`) + +Move OTP logic out of `AuthService` (sendOtp/checkOtp/attempts/blocking). + +### 3.1 Responsibilities +- `generateOtp()` → uses the new `generateSecureOtp()` from `common/utils` (§4.2). +- `startSend(phoneNumber)` / `verify(phoneNumber, code)` implementing the **existing** state machine: + send attempts, check attempts, `blockedUntil`, send window, expiry — preserve exact current semantics + (characterization-tested first). +- Reads limits via an injected `OtpConfig` value object (built once from `ConfigService` in the module + factory) — this also fixes **C-1**: `MAX_SEND_ATTEMPTS` (no trailing space) is the only key read. + +### 3.2 Cache store +`AuthService`'s fragile `cacheManager.stores[1]` (C-3) becomes a named provider `OTP_CACHE` token +bound to the OTP Keyv store in `auth.module.ts`. No positional store access anywhere. + +### 3.3 Tests — `otp.service.spec.ts` +- send happy path (store + sms called once, attempts incremented) +- OTP not expired → `OtpNotExpired` (401) +- block path: `checkIfBlocked` (429), send-limit (429), check-limit (429) +- expired OTP → `OtpExpired` (401) +- wrong code increments check attempts → block after limit +- SMS failure propagates without caching a usable OTP +- fake timers for `lastSendAttempt`, `expiresIn`, `blockedUntil` + +--- + +## 4. Task 2 — Secure code generation (S-6, S-7) + +`src/common/utils/codes.ts` (move + fix): +- `generateSecureOtp()` — **6-digit, zero-padded**: `crypto.randomInt(0, 1_000_000).toString().padStart(6, '0')`. + Removes the biased `[11111, 99999)` range and yields a uniform 6-digit space. **Breaking change** — see §9. +- `generateUniqueCode()` — keep the format for compatibility but document that uniqueness is **not** + guaranteed; introduce `generateTripCode()` / `generateTrackingCode()` as **crypto-random 8-char base32** + (no timestamp prefix) where the DAL unique column enforces uniqueness, with `P2002` retry via + `TransactionRunner` (Phase 4). +- Remove `moment` remnants (already gone); keep Persian helpers in place. + +Tests: `codes.spec.ts` — length/format/padding, range, uniqueness across a large sample, +determinism via a mocked `crypto`. + +--- + +## 5. Task 3 — `TokenService` public API (B-1) + +`src/modules/token/token.service.ts`: +- Promote `generateAccessToken` / `generateTempToken` / `generateProgressToken` to **public** with typed + payloads (`AccessTokenPayload`, `TemporaryTokenPayload`, `ProgressTokenPayload` in `token.types.ts`). +- Expiries become config-driven with the **same defaults**: access `20d`, temp `20m`, progress `1d` + (`JWT_ACCESS_EXPIRES_IN`, `JWT_TEMP_EXPIRES_IN`, `JWT_PROGRESS_EXPIRES_IN`). +- `verifyToken` keeps its behavior and messages; drop the pointless local aliases (B-5). + +Tests — `token.service.spec.ts`: +- each `generate*` produces a JWT verifiable via `verifyToken` with the right type and expiry +- expired token → `"${type} token has expired"`; tampered → `"${type} invalid token"`; + unexpected error → `"${type} verification failed"`; empty/non-string → `InvalidToken` +- config-driven expiry honored (env override in the test) + +--- + +## 6. Task 4 — `AuthStateMachine` (`src/modules/auth/domain/auth-state.machine.ts`) + +Extract `computeTransporterState` into a pure class with an explicit transition table: + +``` +PersonalInfoSubmitted ──(vehicle created)──► VehicleInfoSubmitted +VehicleInfoSubmitted ──(all docs present)──► DocumentsSubmitted +DocumentsSubmitted ──(verificationStatus=verified)──► Authenticated +``` + +- `compute(transporter): { userState, transporter }` — drop-in replacement, same outputs as today. +- `AuthService.getUserState` delegates to it (session get/set stays in the service). +- Add `session-utils.ts` with pure session mutation helpers (`setUserState`, `clearAuthData`). + +Tests: `auth-state.machine.spec.ts` (matrix over vehicle/docs/verification combos), `session-utils.spec.ts`. + +--- + +## 7. Task 5 — Slim `AuthService` + `TransporterSignupService` + +- `auth.service.ts` keeps: `sendOtp`, `checkOtp` (delegating to `OtpService`), `getUserState`, + plus orchestration of the signup flows — with the multi-step bodies **moved** to + `application/transporter-signup.service.ts`. +- One `TransactionRunner` per multi-write op; error wrapping handled by the global filter (services throw + only domain/Nest exceptions — `.catch(formatPrismaError)` removed from auth paths). +- Controller: replace hardcoded cookie objects with `getCookieOptions(config)` (Phase 2, S-2); + move the `session.userState = VehicleInfoSubmitted` mutation out of the controller into a service + call (A-4 mitigation). + +Tests — re-implement `auth.service.spec.ts`, new `transporter-signup.service.spec.ts`: +- checkOtp new-user → temp token + session priming; existing-user → progress token +- signupSender / signupTransporter happy paths + constraint failures +- signupTransporter + vehicle + documents flows incl. `verificationStatusId` handling +- submitDocuments: transaction rollback on failure; access token issued + cookie behavior recorded +- getUserState matrix (cached Authenticated; transporter states; user not found; sender role) + using a mocked `AuthStateMachine` + +--- + +## 8. Task 6 — User & vehicle interplay + +- `user.service.ts` keeps `getTransporter`, `updateTransporter`, `getProfile` but drops direct + `S3Service` dependency → injects the `StoragePort` (Phase 5 defines the port; Phase 3 uses a minimal + interim `S3StoragePort` local interface so tests stay isolated). +- Re-implement `user.service.spec.ts` at 100% (profile URL generation, update stripping phoneNumber, + transporter CRUD, verification status creation, error mapping via filter). + +## 9. Breaking changes (this phase) + +| # | Change | Frontend action | +|---|---|---| +| 3.1 | OTP is now **6 digits** (was 5) and zero-padded | Update SMS/template expectations; OTP input UI must accept 6 digits | +| 3.2 | `JWT_*_EXPIRES_IN` envs — default values unchanged (`20d/20m/1d`) | none (only if envs changed) | +| 3.3 | Session `userState` write for vehicle registration now happens inside the service (same end state) | none (behavior parity) | +| 3.4 | Guard files re-exports only — no API change | none | + +## 10. Definition of Done (checklist) + +- [ ] `OtpService` extracted; `OTP_CACHE` named provider; `MAX_SEND_ATTEMPTS` typo fixed +- [ ] `generateSecureOtp` 6-digit; codes reimplemented with uniqueness enforcement path +- [ ] `TokenService` public API + config-driven expiries; no bracket-notation access anywhere +- [ ] `AuthStateMachine` + `session-utils` pure & tested +- [ ] `AuthService` slim; controller cookie flags centralized; session mutation out of controllers +- [ ] auth/user/token specs re-implemented; module coverage 100/100/100/95 (auth & token paths) +- [ ] `BREAKING-CHANGES.md` updated (3.1 …) +- [ ] `tsc` exit 0, lint 0 errors, `npm test` green + +--- + +## 11. As-built notes + +**Status:** ✅ Completed 2026-09-03. + +### Deviations from spec +- `AuthStateMachine` is an `@Injectable` class (provided in `AuthModule`) rather than a plain + instantiated class, so `AuthService` can receive a mocked instance in tests (spec asked for a + "mocked `AuthStateMachine`" in the `getUserState` matrix). +- `OtpService` reads limits/cache directly via injected `OTP_CONFIG` + `OTP_CACHE` tokens; the + `OTP_CACHE` factory still reads `cacheManager.stores[1]` — now isolated to ONE place in + `auth.module.ts` (fixes C-3; no positional access in services). +- Legacy `generateCode()` kept as deprecated compat export for the trip delivery code + (migrated in Phase 4); `generateSecureOtp()` is the 6-digit zero-padded replacement. +- `UserService` injects an interim `S3StoragePort` bound via `S3_STORAGE_PORT` token to `S3Service` + (`useExisting` in `user.module.ts`). Phase 5 replaces it with the full `StoragePort`. +- `check-otp` DTO `code` is now a **string** (6 digits); `SmsService.sendOtp` accepts a string. +- New optional env keys `JWT_ACCESS_EXPIRES_IN` / `JWT_TEMP_EXPIRES_IN` / `JWT_PROGRESS_EXPIRES_IN` + added to `env.validation.ts` + `config-names.ts`. + +### Coverage (module) +Auth + token specs re-implemented at 100% statements/lines/functions, ≥95% branches +(`codes`, `otp.service`, `auth-state.machine`, `session-utils`, `token.service`, +`auth.service`, `transporter-signup.service`, `auth.controller`, `user.service`). \ No newline at end of file diff --git a/docs/phase-4-core-business.md b/docs/phase-4-core-business.md new file mode 100644 index 0000000..73252d4 --- /dev/null +++ b/docs/phase-4-core-business.md @@ -0,0 +1,272 @@ +# Phase 4 — Core Business: Package, Trip, Matching, Pricing, Turf + +> **Status:** 🚧 In progress (partial: Task 1 + Task 2 landed) · **Spec version:** 1.1 (2026-09-04) +> +> **Audit issues in scope:** A-1 (package 22 KB / trip 32 KB god services), A-2, A-3 (final boilerplate removal), A-4 (session mutation in transactions), B-4, B-9, B-10, S-8 (tracking PII). +> **Depends on:** Phase 2 (filter, runner-friendly error handling), Phase 3 (config/token patterns, session-utils). +> +> **Rollup note:** §10 of this doc is the consolidated as-built for Phases 1–4 +> (per docs-only review 2026-09-04). Phase 1–3 details stay in their own files; +> this file summarizes what actually landed and what remains. + +## 1. Objective + +Dismantle the two biggest service classes into **domain/application services with single +responsibilities**, make pricing a **strategy-driven engine**, extract matching scoring into a pure +scorer, and introduce a `TransactionRunner` that eliminates the repeated +`.catch(formatPrismaError); throw` boilerplate. Then lock everything with 100% tests. + +--- + +## 2. Task 1 — `TransactionRunner` (`src/modules/prisma/transaction-runner.ts`) + +> **Status:** ✅ Landed (`3110252`). + +```ts +@Injectable() +export class TransactionRunner { + constructor(config: ConfigService, private prisma: PrismaService) {} + run(fn: (tx: PrismaTransaction) => Promise, options?: TransactionRunnerOptions): Promise; + runIsolated(fn: (tx: PrismaTransaction) => Promise, options?: TransactionRunnerOptions): Promise; +} +``` + +- Central error conversion is **removed** from this layer: the global `AllExceptionsFilter` (Phase 2) + already maps Prisma errors, so `run` just re-throws. Services stop importing `formatPrismaError` (A-3 done). +- Default `timeout` configurable (`PRISMA_TX_TIMEOUT_MS`, default 5000) with an explicit `TransactionTimedOutError`. + +Tests — `transaction-runner.spec.ts`: success returns value; rollback on inner throw; timeout path; +re-throw preserves original error instance. + +--- + +## 3. Task 2 — Pricing engine & strategies (`src/modules/pricing/strategies/`) + +> **Status:** ✅ Landed (`7985842`). Slim facade preserves the public API consumed by +> `package.service` (`calculateSuggestedPrice` / `calculateTransporterEarnings` / +> `calculateDeviationCost` / `calculateDistanceCost`). + +Replace the monolithic `PricingService` with a composition of testable strategies (same public API + defaults): + +| Strategy | File | Responsibility | Bug fixed | +|---|---|---|---| +| `DistanceTierStrategy` | `distance-tier.strategy.ts` | tiered per-km cost; **guard clause** for `remainingDistance <= 0`; explicit `tierCredit` formula via config bounds (no magic `+1`) | **B-10** | +| `WeightStrategy` | `weight.strategy.ts` | weight surcharge (current: free <500 g) | — | +| `SpecialHandlingStrategy` | `special-handling.strategy.ts` | fragile/perishable/both multipliers | — | +| `CityPremiumStrategy` | `city-premium.strategy.ts` | major-city factors, `PRICING_MAJOR_CITIES` parsing | — | +| `DeviationCostStrategy` | `deviation-cost.strategy.ts` | distance + time deviation | — | +| `EarningsStrategy` | `earnings.strategy.ts` | transporter share (`driverShare`, default 0.7) | — (added during implementation; spec §3 listed 5, code ships 6) | + +- `PricingEngine` orchestrates: `calculateSuggestedPrice(input): { suggestedPrice, breakdown }`, + `calculateTransporterEarnings`, `calculateDeviationCost`. + +- **Decision on C-4:** `PRICING_PLATFORM_COMMISSION` remains unused by design (commission = `1 − driverShare`); + the README env list is corrected in Phase 6, and `env.validation.ts` documents the canonical set. + Alternatively, if the product wants an explicit commission env, that becomes a product decision — tracked here. + +Tests — table-driven per strategy (boundary km tiers, zero weight, multipliers each branch, city premium +each quadrant incl. case-insensitive major-city match, deviation) + engine integration (breakdown math +matches current output for a fixed input — record the golden value). + +--- + +## 4. Task 3 — Matching & Turf (`src/modules/package/matching/`) + +> **Status:** ⬜ Not started (no files under `src/modules/package/matching/` yet; +> `git diff master...HEAD --name-only` shows only `prisma/` + `pricing/` touched). + +Extract pure, highly-tested pieces from `matching.service.ts`: + +- `MatchingScorer` — `calculateMatchingScore(originDistance, destinationDistance, isOnCorridor)` + (current rules: corridor penalty, ±500 close-point bonuses, clamp ≥0). +- `CorridorAnalyzer` — `analyzeTrip(tripRoute, packageOrigin, packageDestination, corridorWidthKm)`: + distance-to-route via TurfService, corridor + direction checks. Returns `MatchResult | null`. +- `TripCandidateQuery` — builds the pre-filter Prisma where-clause (active+scheduled, `updatedAt` >= lastCheck, + weight capacity OR-null) — pure, takes a small typed input. +- `MatchingService` keeps orchestration (session bookkeeping, parallel analysis, merge+sort+limit) + but delegates scoring/analysis; **no console.error** — Nest `Logger` (B-4). +- Open TODOs documented: departure-time filtering, transporter-rating scoring (MVP gap — no behavior change). + +Tests: `matching-scorer.spec.ts` (boundaries: on/off corridor, <1000 m bonuses, zero clamp), +`corridor-analyzer.spec.ts` (in/out corridor, reversed direction, missing waypoints), +`trip-candidate-query.spec.ts` (no lastCheck, with lastCheck, weight present/null), `matching.service.spec.ts` +(session creation, merge/sort/limit, allSettled behavior preserved — including the swallow-null path). + +--- + +## 5. Task 4 — Split `TripService` (32 KB → 3 services) + +> **Status:** ⬜ Not started (no `trip/application/` or `trip/domain/` files yet). + +The 6-dep monolithic `TripService` becomes: + +| Service | File | Responsibilities moved from today | +|---|---|---| +| `TripService` (slim) | `src/modules/trip/application/trip.service.ts` | `create`, `getById`, `getMultipleById`, `getAll`, trip CRUD state helpers | +| `TripRequestService` | `src/modules/trip/domain/trip-request.service.ts` | `updateRequest` (accept/reject), `createRequest`, `cancelRequest`, request list | +| `TripTrackingService` | `src/modules/trip/domain/trip-tracking.service.ts` | tracking code generation, `addTrackingUpdate`, `getTripRoute`, rate-trip | + +Key refactors: +- `create`: keep `calculateDistance` (map) + vehicle-ownership `Forbidden` + notification — but use + `TransactionRunner`, no manual `.catch`. +- `updateRequest`: current `if (rejected)` branch keeps its behavior; the DTO-local enum stays (Phase 1). +- `cancelRequest`: **move the session mutation out of the DB transaction** — call a `session-utils` + helper *after* the tx commits (A-4 mitigation), and add a comment documenting the eventual-consistency + caveat (Redis session is not transactional with Postgres). +- `rateTrip` + financial escrow interplay stays in `TripRequestService` with the intentional + **non-fatal escrow failure** behavior preserved (comment already added in Phase 1). +- `TripController` splits route handling into the three services (public method names preserved for + controller delegation). + +Tests — re-implement `trip.service.spec.ts`; add `trip-request.service.spec.ts` (accept→escrow+notification+ +status; reject→transaction; cancel→session isolation + notification; ownership errors; wrong status enum rejected) +and `trip-tracking.service.spec.ts` (code format, update creation, route assembly, rate validation). + +--- + +## 6. Task 5 — Split `PackageService` (22 KB → 3 services) + +> **Status:** ⬜ Not started (no `package/application/` or `package/domain/` files yet; +> S-8 PII mask and B-9 `JsonArray` removal still pending). + +| Service | File | Responsibilities | +|---|---|---| +| `PackageService` (slim) | `src/modules/package/application/package.service.ts` | package CRUD, price suggestions, status flow, `findMatchedTrips` orchestration | +| `RecipientService` | `src/modules/package/domain/recipient.service.ts` | recipient CRUD + address creation (city→province resolution) | +| `PackageRequestService` | `src/modules/package/domain/package-request.service.ts` | trip requests from package side, cancel request, session update | +| `TrackingService` | `src/modules/package/domain/tracking.service.ts` | `getTrackingByCode` | + +Key refactors: +- `createRecipient`: city lookup + address create in one `TransactionRunner` op; error mapping via filter. +- `findMatchedTrips`/matching orchestration delegates to `MatchingService`; session helpers from `session-utils`. +- `getTrackingByCode` — **S-8 fix**: remove `sender.phoneNumber` from the public response + (masked as `+98•••••123` or dropped) — breaking change listed in §8. +- Remove the internal `generated/prisma/runtime/library` `JsonArray` import (B-9): type the breakdown + payload with an exported `PackageBreakdown` interface. + +Tests — re-implement `package.service.spec.ts`; add recipient/tracking/request specs covering every +status transition + failure path; tracking PII test asserts the phone is masked. + +--- + +## 7. Task 6 — TurfService & leftover cleanup + +> **Status:** ⬜ Not started. + +- `turf.service.ts` stays (pure geometry wrapper) but gains `createRoute`, `createPoint`, + `getDistanceToRoute`, `checkDirectionCompatibility` — all already there; add **100% specs** with + real Turf calls (no mocks needed) incl. `null`/degenerate route inputs. +- `notification-messages.ts`: keep; add spec for placeholder substitution incl. missing-context leaves + `{key}` untouched. +- Remove `MapService→PrismaService` dependency from the package/trip paths if still reachable + (the city lookups move to a `CityRepository` in Phase 5 — referenced here so it is not skipped). + +--- + +## 8. Breaking changes (this phase) + +| # | Change | Frontend action | +|---|---|---| +| 4.0 | **Partial landing (Task 1 + Task 2, 2026-09-04): no API-visible change.** Pricing output is byte-identical by golden parity; `TransactionRunner` is internal only | none | +| 4.1 | `/tracking/:code` response no longer includes `sender.phoneNumber` (masked/dropped, S-8) — **pending** (Task 5 not started) | Stop relying on sender PII in tracking screens | +| 4.2 | Pricing output must remain **byte-identical** (golden test enforced) | none — if drift detected, treat as bug and record here | + +## 9. Definition of Done (checklist) + +- [x] `TransactionRunner` added with configurable timeout + `run`/`runIsolated` (`3110252`); adoption across package/trip paths still pending — A-3 not yet resolved phase-wide +- [x] Pricing engine + 6 strategies with table-driven tests + golden parity (`7985842`); golden output test for backward equality present as `pricing.engine.spec.ts` +- [ ] Matching/scoring extracted and 100% tested; console logging removed (Logger) +- [ ] Trip split complete: request/tracking domain services 100% +- [ ] Package split complete: recipient/request/tracking services 100% +- [ ] Tracking PII masked + tested; `JsonArray` internal import removed +- [ ] Turf & notification-messages specs at 100% +- [ ] session mutation moved out of tx for `cancelRequest` (A-4) +- [ ] `tsc` exit 0, lint 0 errors, `npm test` green; touched modules 100/100/100/95 — **partial:** `tsc` clean, pricing+prisma 10 suites / 98 tests green, lint has 17 errors (see §10.6) +- [ ] `BREAKING-CHANGES.md` updated (4.0 no-op entry added; 4.1 still pending) + +--- + +## 10. As-built notes + +> Docs-only review 2026-09-04 (branch `refactor/core-business` @ `7985842`). +> No `src/` changes in this review — deviations are recorded as debt for follow-up. + +### 10.1 Status snapshot + +- Phase 4 Tasks 1–2 landed; Tasks 3–6 untouched. Proof: `git diff master...HEAD --name-only` + lists only `src/common/config/*`, `src/modules/pricing/**`, `src/modules/prisma/transaction-runner*`. +- `src/modules/package/matching/`, `trip/application/`, `trip/domain/`, + `package/application/`, `package/domain/` do not exist yet. + +### 10.2 Rollup — what Phases 1–4 actually changed + +| Phase | Commit | Landed change | Evidence | +|---|---|---|---| +| 1 — Foundation | `06e0ae9` | Jest scoped to `src/**/*.ts`, `isolatedModules` (514 s → ~20 s), ESLint flat type-checked config, `moment` removed (`date-fns` rewrite), `jest-mock-extended` → devDeps, `test/fixtures/` (user/session/location) | `docs/phase-1-foundation.md` as-built; baseline 68.13/59.82/41.80/67.53 | +| 2 — Bootstrap/Config/Security | `cfb158a` | `EnvSchema` + `ConfigKey`, `getCookieOptions`, `parseCorsOrigins`/swagger helpers, `PrismaErrorMapper` + `AllExceptionsFilter`, `Transform`/`Logging`/`Timeout` + `RequestIdMiddleware`, `infra/session/` (cookie-parser first, sync wiring, quit-on-shutdown), `BaseTokenGuard` + 5 guards (B-2 merge fix), `main.ts` hardened (helmet, compression, CORS env, prefix, shutdown, swagger gate, Logger), health external ping removed | `docs/phase-2-security-config.md` §11–12; `BREAKING-CHANGES.md` 2.1–2.5 applied | +| 3 — Auth/Session/User | `ea84577` | `codes.ts` (`generateSecureOtp` 6-digit zero-padded; legacy `generateCode` kept deprecated), `TokenService` public typed API + `JWT_*_EXPIRES_IN` (20d/20m/1d), `OtpService` + `OTP_CACHE`/`OTP_CONFIG` (fixes C-1 trailing-space key, C-3 positional store), `AuthStateMachine` (Injectable for mocking), `TransporterSignupService`, `session-utils.ts`, `check-otp.code: string`, `SmsService.sendOtp(string)`, interim `S3_STORAGE_PORT` in user | `docs/phase-3-auth-session.md` §11; `BREAKING-CHANGES.md` 3.1–3.4 applied | +| 4 partial — Runner | `3110252` | `TransactionRunner` (`run` + `runIsolated` serializable), `ConfigKey.Database.TransactionTimeoutMs` + `PRISMA_TX_TIMEOUT_MS` (default 5000), `P2028 → TransactionTimedOutError`, re-throw otherwise (filter owns mapping), wired in `prisma.module.ts` + `transaction-runner.spec.ts` | §10.3 | +| 4 partial — Pricing | `7985842` | `PricingEngine` (pure pipeline, legacy arithmetic order preserved) + 6 strategies + `buildPricingStrategies` factory + slim `PricingService` facade (same 4 public methods); B-10 tier spans derived (`upToKm − previousUpToKm`); table-driven specs + golden-equality engine spec + facade spec | §10.4 | + +### 10.3 Task 1 as-built (`TransactionRunner`) + +- Files: `src/modules/prisma/transaction-runner.ts`, `transaction-runner.spec.ts`, + `prisma.module.ts`, `src/common/config/config-names.ts`, `env.validation.ts` + (`PRISMA_TX_TIMEOUT_MS` optional int). +- Extension vs spec §2: constructor takes `ConfigService` (not just `PrismaService`); + `run(fn, options?)` / `runIsolated(fn, options?)` with `{ timeoutMs, isolationLevel }`; + `runIsolated` forces `Serializable` unless overridden. Documented here; spec signature above updated. +- Behavior: timeout resolved per-call (`timeoutMs ?? defaultTimeoutMs`) and passed to + `prisma.$transaction(fn, { timeout, isolationLevel })`; only `code === 'P2028'` maps to + `TransactionTimedOutError`, everything else re-thrown untouched for `AllExceptionsFilter`. +- Tests (`transaction-runner.spec.ts`): success value, rollback on inner throw, + timeout mapping, original-error preservation. Part of the 10-suite / 98-test green run (§10.7). + +### 10.4 Task 2 as-built (Pricing) + +- Files: `pricing.engine.ts`, `pricing.service.ts` (facade), `strategies/`: + `distance-tier`, `weight`, `special-handling`, `city-premium`, `deviation-cost`, + `earnings`, `pricing-factory` (+ 8 co-located specs: engine, facade, 6 strategy/factory specs). +- Parity: `PricingEngine.calculateSuggestedPrice` keeps legacy order + (base + distance + weight → `× specialMultiplier` → `× cityPremium` → round to nearest 1000); + `DistanceTier.getSpanKm()` = `upToKm − previousUpToKm` (Infinity for open tier) with + `remainingDistance <= 0` guard, reproducing the legacy `maxKm − minKm + 1` output without the `+1`. + `CityPremiumStrategy` is case-insensitive over `parseMajorCities(...)`. +- Commit message cites a 500-check parity harness + golden capture proving byte-identical + output across 5 config variants. No `pricing-golden.json` is committed in the diff — + treat the harness as ephemeral and `pricing.engine.spec.ts` as the durable golden test. +- C-4 decision stands: `PRICING_PLATFORM_COMMISSION` stays unused (`commission = 1 − driverShare`); + README correction deferred to Phase 6. + +### 10.5 Remaining (Tasks 3–6 — not started) + +- Matching scorer / corridor analyzer / candidate query + `Logger` swap (B-4). +- Trip split, package split (incl. S-8 PII mask, B-9 `JsonArray` removal, A-4 session-out-of-tx). +- Turf 100% specs + `notification-messages` spec + `MapService → CityRepository` (Phase 5 ref). + +### 10.6 Deviations / documented debt (docs-only — no code fix in this review) + +| # | Drift | Location | Follow-up | +|---|---|---|---| +| D-1 | Spec listed 5 strategies; code ships 6 (`EarningsStrategy`) | §3 table | Table updated; no action | +| D-2 | `pricing-factory.ts` uses string literals `'PRICING_*'` instead of `ConfigKey.Pricing.*` | `strategies/pricing-factory.ts:16-45` | Migrate to `ConfigKey` when pricing is next touched (CONVENTIONS §4) | +| D-3 | `EnvSchema` types all `PRICING_*` multipliers/share as `@IsInt()` — rejects valid floats (`1.25`, `0.7`) | `env.validation.ts:85-108` | Change to `@IsNumber()` + range check in a later phase | +| D-4 | Factory defaults diverge from `README.md` env table (weight 10000 vs 8000; dest 1.3 vs 1.2; small 1.2 vs 1.15; deviation 15000/5000 vs 2000/1500; tiers 1000/950/850/750/600 vs 1200/1000/850/750/650; major-cities 3 vs 6) | `pricing-factory.ts:15-78` vs `README.md` | Reconcile in Phase 6 README pass (C-4); treat factory as source of truth until then | +| D-5 | Lint: 1 new Phase-4 error (`pricing.engine.spec.ts:12 TABRIZ` unused) + 16 pre-existing errors (unused imports in Phase-2 specs/filter, `health.controller.spec.ts` floating promise) + 236 warnings | `npm run lint` 2026-09-04 | Fix `TABRIZ` + unused imports with the phase that owns each file | +| D-6 | Golden harness not committed | commit `7985842` message | Keep `pricing.engine.spec.ts` as durable proof; optionally commit harness artifact later | + +### 10.7 Verification performed (2026-09-04, branch `refactor/core-business`) + +- `npx tsc --noEmit` — exit 0. +- `npm test -- src/modules/pricing src/modules/prisma` — 10 suites / 98 tests passed. +- `npm run lint` (= `eslint --fix` per `package.json`) — converges to **17 errors / 236 warnings** + (1 new Phase-4 error `pricing.engine.spec.ts:12 TABRIZ` unused + 16 pre-existing in Phase-2 + specs/filter + `health.controller.spec.ts` floating promise; see D-5). The `--fix` formatting + side effects on `src/` were reverted (`git restore src/`) to keep this review docs-only. + Note: raw `eslint --no-fix` on a Windows checkout reports ~19k `prettier/prettier Delete ␍` + line-ending hits — pre-existing CRLF env noise, not real debt. +- `git diff master...HEAD --stat` — 23 files, +1113/−723, confined to config/pricing/prisma. +- Full-suite `npm test` / `test:cov` not re-run here; commit `7985842` records 53/53 suites, 401/401 tests. +- Docs plumbing: `/docs` removed from `.gitignore` (it was added together with a mass + `docs/` deletion in `2c50a47`, which is why `docs/` was untracked) so this improvement set is + committable; `/generated` stays ignored. Root `phase-1-foundation.md` deleted (docs/ canonical). \ No newline at end of file diff --git a/docs/phase-5-supporting-modules.md b/docs/phase-5-supporting-modules.md new file mode 100644 index 0000000..8330291 --- /dev/null +++ b/docs/phase-5-supporting-modules.md @@ -0,0 +1,128 @@ +# Phase 5 — Supporting Modules & Infrastructure Adapters + +> **Status:** ⬜ Not started · **Spec version:** 1.0 (2026-09-02) +> +> **Audit issues in scope:** A-2 (layering / MapService→Prisma), B-4 (console logging), B-8, O-1, remaining `no-unsafe-*`/`require-await` lint warnings on these modules. +> **Depends on:** Phases 2–4 (filters, runner, conventions, ports patterns). + +## 1. Objective + +Finish the layering: extract the external-world access behind **ports + adapters** (`src/infra/`), +remove the remaining module gods (`user`, `financial`, `map`, `dashboard`, `notification`, support, +vehicle, address), standardize DTO response mapping, and reach **100/100/100/95** on every module. + +--- + +## 2. Task 1 — Infra adapters (`src/infra/`) + +### 2.1 `src/infra/ports/` (interfaces, no Nest) +| Port | Methods | Used by | +|---|---|---| +| `StoragePort` | `generatePutPresignedUrl(key, expiresIn)`, `generateGetPresignedUrl(key, expiresIn)`, `deleteFile(key)`, `fileExists(key)` | user, package, trip, s3, dashboard | +| `SmsPort` | `sendSms(mobiles[], message)`, `sendOtp(mobile, code)` | auth | +| `MapsPort` | `calculateDistance(input)`, `reverseGeocode(location)`, `routeDirections(input)` | map, trip, package, turf | +| `CodeGeneratorPort` (optional) | `otp()`, `tripCode()`, `trackingCode()` | auth, trip, package | + +### 2.2 Implementations (`src/infra/**/`) +- `storage/s3-storage.adapter.ts` — **move** S3 client logic from `s3.service.ts` (client creation from + config, presign, delete, exists). `S3Service` becomes a thin `@Injectable` delegating to the adapter + (keeps existing importers working) **or** is deleted with imports migrated to the port token. + Decision marker: prefer deleting `s3.service.ts` if the diff stays small (controllers import the token). +- `sms/sms.adapter.ts` — move current `SmsService` HTTP logic; keep exact URLs/headers/payloads; + add a **retry-once** on 5xx (documented deviation) and keep error messages identical. +- `maps/neshan-maps.adapter.ts` — move direction/reverse-geocode calls from `map.service.ts`. + +### 2.3 Provider binding +`PORTS.STORAGE` / `PORTS.SMS` / `PORTS.MAPS` tokens exported from `src/infra/ports/ports.tokens.ts`; +bound in the owning modules (`S3Module`/`SmsModule`/`MapModule`) so unit tests can `overrideProvider`. + +Tests — adapter specs with mocked `HttpService`/`S3Client`: success payloads, error propagation, +presign argument shape, header/cookie placement. + +--- + +## 3. Task 2 — `MapService` refactor (A-2 fix) + +- `src/modules/map/map.service.ts` becomes an application service using `MapsPort` **only**. +- City lookups (`getIntermediateCitiesWithIds`) move to a new `CityRepository` + (`src/modules/prisma/repositories/city.repository.ts`) injected into map/package/trip — this removes + the Prisma dependency from the infra client for good. +- `getIntermediateCitiesWithCoords/WithIds` keep their endpoints; `extractSignificantPoints` and + `haversineDistance` become exported pure helpers (`map/route-filters.ts`) with **100% tests** + (roundabout steps, `وارد` instruction, min-distance dedup, empty legs). + +Tests: `route-filters.spec.ts`, `city.repository.spec.ts`, `map.service.spec.ts` (HttpService mocked). + +--- + +## 4. Task 3 — Remaining module services + +### 4.1 `UserService` +- Switch S3 calls → `StoragePort`; `getProfile` mapping stays (response DTOs via `Serialize`). +- Remove `formatPrismaError` wrappers (global filter). Update `user.service.spec.ts` for the port mock. + +### 4.2 `FinancialService` +- Split concerns: `WalletService` (getWallet, addFunds), `EscrowService` (createEscrow/release/refund). +- Decimal-safety: BigInt amounts stay as `bigint` internally; **all** public conversions use a single + `formatMoney(bigint)` helper (`common/utils/money.ts`, unit tested incl. negative/zero). +- Payment gateway integration stays a documented TODO (no schema/env budget). + +### 4.3 `NotificationService` +- Replace `console.*` with Nest `Logger`; add `unreadCount` query; keep `create(content, tx)` signature. +- Specs: create inside/outside tx, unread listing, placeholder substitution via `notification-messages`. + +### 4.4 `DashboardService` +- Already reads through `getDateDifference` (date-fns, Phase 1). Remove `formatPrismaError` wrappers; + inject `StoragePort`; single source for statistics builders; 100% specs for both role branches. + +### 4.5 `VehicleService` / `AddressService` / `SupportService` +- Thin services: keep logic, use `TransactionRunner`, remove `formatPrismaError`; response mapping via + existing `*ResponseDto`s; `SupportService.updateVerification` guards against missing + `verificationStatusId` with a `NotFoundException` (documented behavior addition). + +### 4.6 `S3Controller` +- Add the missing upload **size limit** (audit TODO): `MAX_UPLOAD_SIZE_MB` env + presign-time + content-length handling or a pre-upload validation endpoint (documented decision marker). + +--- + +## 5. Task 4 — Full-module test sweep + +Each touched module gets its `*.service.spec.ts` and `*.controller.spec.ts` re-implemented: +assert **mapping and delegation**, not just "returns the mock". Cover: +- controller 400/404/200 wiring per route, Swagger decorators not asserted +- services: happy path, each throw path, transaction rollback, BigInt formatting +- `@Serialize(ResponseDto)` interceptor path on the response objects (use real `plainToInstance`) + +**Coverage gate for the phase:** every module directory listed in this phase at 100/100/100/95. + +--- + +## 6. Breaking changes (this phase) + +| # | Change | Frontend action | +|---|---|---| +| 5.1 | `SupportService.updateVerification` throws **404** when transporter has no `verificationStatusId` (was a possible 500) | none normally; handle 404 in admin UI | +| 5.2 | S3 upload size limit enforced (env `MAX_UPLOAD_SIZE_MB`) | Upload UI should pre-check file size | +| 5.3 | `S3Service` → port token refactor is internal (same endpoints) | none | + +--- + +## 7. Definition of Done (checklist) + +- [ ] `src/infra/` exists with `StoragePort`/`SmsPort`/`MapsPort` + adapters; no direct AWS/SMS/Neshan + calls outside adapters +- [ ] `MapService` no longer depends on `PrismaService`; `CityRepository` extracted +- [ ] `User`, `Financial` (wallet/escrow), `Notification`, `Dashboard`, `Vehicle`, `Address`, `Support` + cleaned (runner, filter, ports, Logger) +- [ ] S3 size limit shipped and tested +- [ ] Every module spec (service + controller) re-implemented; phase modules at 100/100/100/95 +- [ ] `tsc` exit 0, lint **0 errors** (warnings backlog significantly reduced — `no-unsafe-*` gone on + touched files), `npm test` green +- [ ] `BREAKING-CHANGES.md` updated (5.1–5.3) + +--- + +## 8. As-built notes + +*(Filled when the phase completes — record deviations, actual coverage numbers, timings.)* \ No newline at end of file diff --git a/docs/phase-6-integration-ci.md b/docs/phase-6-integration-ci.md new file mode 100644 index 0000000..8c67a60 --- /dev/null +++ b/docs/phase-6-integration-ci.md @@ -0,0 +1,117 @@ +# Phase 6 — Integration Tests, CI & Final Docs + +> **Status:** ⬜ Not started · **Spec version:** 1.0 (2026-09-02) +> +> **Audit issues in scope:** O-2 (partial), T-1 (coverage gate), T-5 (dead e2e), T-6 (CI secrets), C-7 (docker/entrypoint), O-3. +> **Depends on:** Phases 1–5 (all code refactored, all unit tests re-implemented). + +## 1. Objective + +Prove the whole thing end-to-end against **real Postgres + Redis** in CI, turn the 100/95 coverage +targets into an enforced CI gate, retire the scaffold e2e test, update ops files (`entrypoint.sh`, +`Dockerfile`, compose), and finalize documentation including the frontend digest. + +--- + +## 2. Task 1 — Integration test suite (`test/integration/`) + +### 2.1 Infrastructure +- `docker-compose.test.yml` — postgres:17 + redis:8 (same images/versions as `docker-compose.dev.yml`), + no app service; fixed host ports for local runs. +- `test/integration/setup.ts` (Jest `globalSetup`) — sets `DATABASE_URL`/`REDIS_URL`/`SESSION_REDIS_URL` + to the test services, runs `prisma db push` (schema is unchanged, so no migrations needed), then + truncates tables between suites via a helper. + +### 2.2 Test categories +| Suite | What it proves | +|---|---| +| `auth.integration.spec.ts` | real OTP store (Redis) flow: send → verify → session/token issuance; rate-limit block | +| `users.integration.spec.ts` | user + transporter CRUD against real DB, unique-constraint 409s | +| `packages.integration.spec.ts` | recipient/address create with city→province; package flow; tracking PII check | +| `trips.integration.spec.ts` | trip create w/ ownership; request accept → escrow; tracking update assembly | +| `pricing.integration.spec.ts` | golden price equals unit-test golden (config wired correctly) | +| `health.integration.spec.ts` | `/health` responds OK when DB+Redis up | + +External calls (SMS, Neshan, S3) are **mocked at the port boundary** (from Phase 5) via provider overrides +in the integration `TestingModule` — no network, no secrets (T-6). + +### 2.3 Runner wiring +- `test/jest-integration.json` (testEnvironment node, `setupFilesAfterEnv`, longer timeout 30s). +- `npm run test:integration` — runs db push (against `TEST_DATABASE_URL`) + integration suite. + +--- + +## 3. Task 2 — CI pipeline rewrite (`.github/workflows/test-build.yml`) + +``` +jobs: + test: # lint + types + unit (NO secrets) + steps: npm ci → prisma generate → lint → tsc --noEmit → npm test → coverage report (upload artifact) + integration: # needs test + services: postgres + redis (as today) + steps: npm ci → prisma generate → prisma db push → npm run test:integration + build: # needs integration, only on master push + steps: docker build/push (unchanged target runtime) +``` + +- Unit job env: **remove all secrets** (only non-secret `NODE_ENV=test`). +- Integration job: only `TEST_DATABASE_URL` style vars + mocked port overrides (no AWS/SMS/MAP secrets). +- Coverage gate lives in Jest (`coverageThreshold`), so CI fails naturally — plus upload + `coverage/lcov-report` as an artifact. + +### 3.1 Jest `coverageThreshold` (global) +```json +"coverageThreshold": { + "global": { "statements": 100, "functions": 100, "lines": 100, "branches": 95 } +} +``` +Rationale: policy from TESTING-STRATEGY; `main.ts` + `*.module.ts` stay excluded. If a carve-out is +needed, it is added to `TESTING-STRATEGY.md` with justification **before** the gate is switched on. + +--- + +## 4. Task 3 — Ops files +- `entrypoint.sh`: split into `entrypoint.sh` (schema push only) and **seed as an explicit, idempotent + npm script** (`seed` already exists) — **stop seeding on every container boot** (O-3); add + `SEED_ON_BOOT` env (default false) honored by the script. +- `Dockerfile`: verify `dev`/`runtime` targets still match new `npm run` scripts; no schema change. +- `prisma/.env`: remove from git; document in `.env.example` (C-7). +- Add `npm run prisma:generate:ci`? Not needed — CI already calls `prisma generate`. + +--- + +## 5. Task 4 — e2e smoke suite (`test/e2e/`) +Retire `test/app.e2e-spec.ts` ("Hello World!") and replace with: +- `health.e2e-spec.ts` — boots the full `AppModule` with ports mocked, asserts `/health` 200. +- `validation.e2e-spec.ts` — POST to a known route with an unknown field → 400 standardized envelope. +- `not-found.e2e-spec.ts` — unknown route → 404 envelope incl. `requestId` (Phase 2 filter). +These run with the **same infra as integration** (postgres + redis) via `npm run test:e2e`. + +--- + +## 6. Task 5 — Final documentation & frontend digest +- Update `README.md`: testing section (three tiers), coverage badge, new env vars (`CORS_ORIGINS`, + `API_PREFIX`, `THROTTLE_*`, `JWT_*_EXPIRES_IN`, `MAX_UPLOAD_SIZE_MB`, `TIMEOUT_MS`, `LOG_LEVEL`), + corrected pricing env table. +- `BREAKING-CHANGES.md`: consolidate all entries (2.x–5.x) into a single **frontend migration guide** + with FAQ + timeline. +- `REFACTORING-PLAN.md`: final progress tracker; close all issues in the disposition table. +- `CODEBASE-AUDIT.md`: refresh severity table (what's fixed), keep as historical record. + +--- + +## 7. Definition of Done (final, whole-project) + +- [ ] Integration suite green in CI against Dockerized Postgres/Redis (no secrets) +- [ ] e2e smoke suite green; scaffold "Hello World!" removed +- [ ] Coverage gate enforced — **global 100/100/100/95**; CI fails below with artifact +- [ ] CI: test(lint+unit) → integration → build(push) all green +- [ ] Ops files updated (no per-boot seeding; `prisma/.env` untracked) +- [ ] README + docs finalized; `BREAKING-CHANGES.md` digest delivered to frontend +- [ ] Final full run: `npm run lint` (0 errors), `npx tsc --noEmit`, `npm run test:cov`, `npm run test:integration` + +--- + +## 8. As-built notes + +*(Filled when the phase completes — record deviations, actual coverage numbers, CI timings.)* \ No newline at end of file diff --git a/phase-1-foundation.md b/phase-1-foundation.md deleted file mode 100644 index f58b637..0000000 --- a/phase-1-foundation.md +++ /dev/null @@ -1,29 +0,0 @@ -# Phase 1: Foundation - -This phase establishes the core foundation for the application, including: - -- Core module structure and organization -- Authentication and authorization setup -- Database schema and Prisma configuration -- Core enums and constants -- Shared utilities and interceptors -- Basic module scaffolding - -## Modules Covered - -- Auth (OTP, sender, authorization) -- User management -- Vehicle management -- Package handling -- Pricing and financial operations -- Notifications and messaging -- S3 storage integration -- Dashboard and mapping features - -## Key Decisions - -- NestJS module organization with feature-based grouping -- Prisma ORM for database interactions -- JWT-based authentication -- S3 for file storage -- Class-validator for DTO validation \ No newline at end of file From a9dae443e6907f2e4d1da51fda98fdae3a8b4a66 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Sat, 3 Oct 2026 16:57:51 +0330 Subject: [PATCH 4/5] refactor(matching): extract scorer, candidate-query and corridor-analyzer with parity specs - Add pure MatchingScorer, buildTripCandidateWhere and CorridorAnalyzer under src/modules/package/matching/ (moved verbatim, no behavior change) - Slim MatchingService to orchestration: delegates scoring/analysis, Nest Logger instead of console.error, ConfigKey.Pricing.CorridorWidth - Fix TurfService console.error -> Logger (fallback path now covered) - Add table-driven specs + match fixtures; extend matching.service.spec (Logger skip, ConfigKey read/default, default-param paths) Verification: tsc clean, 56 suites / 437 tests green, scoped eslint 0 errors. No API-visible change (BREAKING 4.0 stands). --- docs/INDEX.md | 2 +- docs/REFACTORING-PLAN.md | 2 +- docs/phase-4-core-business.md | 32 +++- src/modules/package/matching.service.spec.ts | 111 ++++++++++++ src/modules/package/matching.service.ts | 129 ++++--------- .../matching/corridor-analyzer.spec.ts | 171 ++++++++++++++++++ .../package/matching/corridor-analyzer.ts | 81 +++++++++ .../package/matching/matching-scorer.spec.ts | 55 ++++++ .../package/matching/matching-scorer.ts | 39 ++++ .../matching/trip-candidate-query.spec.ts | 70 +++++++ .../package/matching/trip-candidate-query.ts | 40 ++++ src/modules/turf/turf.service.spec.ts | 47 +++++ src/modules/turf/turf.service.ts | 6 +- test/fixtures/index.ts | 1 + test/fixtures/match.fixture.ts | 68 +++++++ 15 files changed, 749 insertions(+), 105 deletions(-) create mode 100644 src/modules/package/matching/corridor-analyzer.spec.ts create mode 100644 src/modules/package/matching/corridor-analyzer.ts create mode 100644 src/modules/package/matching/matching-scorer.spec.ts create mode 100644 src/modules/package/matching/matching-scorer.ts create mode 100644 src/modules/package/matching/trip-candidate-query.spec.ts create mode 100644 src/modules/package/matching/trip-candidate-query.ts create mode 100644 test/fixtures/match.fixture.ts diff --git a/docs/INDEX.md b/docs/INDEX.md index 95644b1..eb603c5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -24,7 +24,7 @@ One `phase-N-*.md` file per phase — the **implementation spec + as-built recor | [phase-1-foundation.md](./phase-1-foundation.md) | Phase 1 spec + as-built | ✅ Completed | | [phase-2-security-config.md](./phase-2-security-config.md) | Phase 2 spec + as-built | ✅ Completed | | [phase-3-auth-session.md](./phase-3-auth-session.md) | Phase 3 spec + as-built | ✅ Completed | -| [phase-4-core-business.md](./phase-4-core-business.md) | Phase 4 spec + partial as-built + **Phases 1–4 rollup (§10)** | 🚧 In progress (Task 1 + Task 2 landed) | +| [phase-4-core-business.md](./phase-4-core-business.md) | Phase 4 spec + partial as-built + **Phases 1–4 rollup (§10)** | 🚧 In progress (Tasks 1–3 landed) | | [phase-5-supporting-modules.md](./phase-5-supporting-modules.md) | Phase 5 spec (modules + infra adapters) | ✅ Spec complete | | [phase-6-integration-ci.md](./phase-6-integration-ci.md) | Phase 6 spec (integration, CI, final gates) | ✅ Spec complete | diff --git a/docs/REFACTORING-PLAN.md b/docs/REFACTORING-PLAN.md index f42a497..6dd1c0b 100644 --- a/docs/REFACTORING-PLAN.md +++ b/docs/REFACTORING-PLAN.md @@ -152,7 +152,7 @@ Every audited issue is either resolved by a phase or explicitly waived: | 1 — Foundation | ✅ Completed | — | baseline: 68.13/59.82/41.80/67.48 | suite 514s → ~20s; ESLint added; **all docs complete** ([phase-1-foundation.md](./phase-1-foundation.md)) | | 2 — Security/Config | ✅ Completed | env.validation, cookie-options, bootstrap-options, prisma-error-mapper, all-exceptions.filter, transform/logging/timeout, guards (5), session provider, health | 100/100/100 (phase-2 paths ≥95 branches; lint debt tracked in Phase 4 §10.6 D-5) | `cfb158a` 40 files; BREAKING-CHANGES 2.1–2.5 applied ([phase-2-security-config.md](./phase-2-security-config.md)) | | 3 — Auth/Session | ✅ Completed | codes, otp.service, auth-state.machine, session-utils, token.service, auth.service, transporter-signup.service, auth.controller, user.service | 100/100/100 (auth & token paths ≥95 branches) | OTP 6-digit + OtpService + OTP_CACHE; TokenService public API; AuthStateMachine + session-utils; slim AuthService + TransporterSignupService; S3StoragePort in user ([phase-3-auth-session.md](./phase-3-auth-session.md)) | -| 4 — Core Business | 🚧 In progress (Task 1 + Task 2) | transaction-runner, pricing engine + 6 strategies + factory + facade | pricing+prisma 10 suites / 98 tests green (2026-09-04); module 100/100/100/95 claimed in commits, lint 17 errors tracked | Runner + pricing landed (`3110252`, `7985842`); matching/trip/package/turf pending; **Phases 1–4 rollup in §10** ([phase-4-core-business.md](./phase-4-core-business.md)) | +| 4 — Core Business | 🚧 In progress (Tasks 1–3) | transaction-runner, pricing engine + 6 strategies + factory + facade, matching scorer + candidate-query + corridor-analyzer | pricing+prisma 10 suites / 98 tests green (2026-09-04); module 100/100/100/95 claimed in commits, lint 17 errors tracked | Runner + pricing landed (`3110252`, `7985842`); matching/trip/package/turf pending; **Phases 1–4 rollup in §10** ([phase-4-core-business.md](./phase-4-core-business.md)) | | 5 — Supporting Modules | ⬜ Not started | — | — | spec complete ([phase-5-supporting-modules.md](./phase-5-supporting-modules.md)) | | 6 — Integration & CI | ⬜ Not started | — | — | spec complete ([phase-6-integration-ci.md](./phase-6-integration-ci.md)) | diff --git a/docs/phase-4-core-business.md b/docs/phase-4-core-business.md index 73252d4..4f5882a 100644 --- a/docs/phase-4-core-business.md +++ b/docs/phase-4-core-business.md @@ -1,6 +1,6 @@ # Phase 4 — Core Business: Package, Trip, Matching, Pricing, Turf -> **Status:** 🚧 In progress (partial: Task 1 + Task 2 landed) · **Spec version:** 1.1 (2026-09-04) +> **Status:** 🚧 In progress (partial: Tasks 1–3 landed) · **Spec version:** 1.2 (2026-10-03) > > **Audit issues in scope:** A-1 (package 22 KB / trip 32 KB god services), A-2, A-3 (final boilerplate removal), A-4 (session mutation in transactions), B-4, B-9, B-10, S-8 (tracking PII). > **Depends on:** Phase 2 (filter, runner-friendly error handling), Phase 3 (config/token patterns, session-utils). @@ -72,8 +72,7 @@ matches current output for a fixed input — record the golden value). ## 4. Task 3 — Matching & Turf (`src/modules/package/matching/`) -> **Status:** ⬜ Not started (no files under `src/modules/package/matching/` yet; -> `git diff master...HEAD --name-only` shows only `prisma/` + `pricing/` touched). +> **Status:** ✅ Landed (branch `refactor/phase-4-matching`). Extract pure, highly-tested pieces from `matching.service.ts`: @@ -175,7 +174,7 @@ status transition + failure path; tracking PII test asserts the phone is masked. - [x] `TransactionRunner` added with configurable timeout + `run`/`runIsolated` (`3110252`); adoption across package/trip paths still pending — A-3 not yet resolved phase-wide - [x] Pricing engine + 6 strategies with table-driven tests + golden parity (`7985842`); golden output test for backward equality present as `pricing.engine.spec.ts` -- [ ] Matching/scoring extracted and 100% tested; console logging removed (Logger) +- [ ] Matching/scoring extracted and 100% tested; console logging removed (Logger) — **done for matching (Task 3); Turf full specs remain Task 6** - [ ] Trip split complete: request/tracking domain services 100% - [ ] Package split complete: recipient/request/tracking services 100% - [ ] Tracking PII masked + tested; `JsonArray` internal import removed @@ -269,4 +268,27 @@ status transition + failure path; tracking PII test asserts the phone is masked. - Full-suite `npm test` / `test:cov` not re-run here; commit `7985842` records 53/53 suites, 401/401 tests. - Docs plumbing: `/docs` removed from `.gitignore` (it was added together with a mass `docs/` deletion in `2c50a47`, which is why `docs/` was untracked) so this improvement set is - committable; `/generated` stays ignored. Root `phase-1-foundation.md` deleted (docs/ canonical). \ No newline at end of file + committable; `/generated` stays ignored. Root `phase-1-foundation.md` deleted (docs/ canonical). + +### 10.8 Task 3 as-built (2026-10-03, branch `refactor/phase-4-matching`) + +- Files: `src/modules/package/matching/matching-scorer.ts` (+spec), + `trip-candidate-query.ts` (+spec), `corridor-analyzer.ts` (+spec); + slimmed `src/modules/package/matching.service.ts` (delegates, `Logger`, + `ConfigKey.Pricing.CorridorWidth`); `turf.service.ts` `console.error` → `Logger`; + `test/fixtures/match.fixture.ts` (+ barrel export). +- Parity: scorer/query/analyzer moved verbatim; `MatchingService` keeps public + signature + session merge/sort/limit + swallow-`null` `allSettled` behavior; + private `getPreFilteredTrips`/`analyzeTrip`/`calculateMatchingScore` kept as thin + delegates (backward-compatible for spies). No API-visible change (BREAKING 4.0 stands). +- Decisions: `ConfigKey` used now (pays down D-2 pattern for matching; pricing + factory migration still pending); `CorridorAnalyzer` is a plain class constructed + inside the service (pricing-facade pattern — no module wiring change). +- Gotcha: `expect.anything()` does **not** match `jest-mock-extended` `mockDeep` + proxies — assert by reference (`prismaService`) instead. +- Verification: `tsc` exit 0; `npm test -- src/modules/package src/modules/turf + src/modules/prisma src/modules/pricing` — 17 suites / 183 tests green; + scoped `eslint --fix` on touched files — **0 errors** (2 `require-await` + warnings: `analyzeTrip` stays `async` for the `.catch` chain — same as before). + Matching units at 100/100/100/95+; `turf.service.ts` full 100% remains Task 6 + (only the touched `getDistanceToRoute` fallback path is now covered). \ No newline at end of file diff --git a/src/modules/package/matching.service.spec.ts b/src/modules/package/matching.service.spec.ts index 8baa936..8309523 100644 --- a/src/modules/package/matching.service.spec.ts +++ b/src/modules/package/matching.service.spec.ts @@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config'; import { PrismaClient, TripStatusEnum } from '../../../generated/prisma'; import { TurfService } from '../turf/turf.service'; import { PrismaService } from '../prisma/prisma.service'; +import { ConfigKey } from '../../common/config/config-names'; import { PackageWithLocations, TripWithLocations, @@ -654,5 +655,115 @@ describe('MatchingService', () => { it('should handle corridor width configuration', () => { expect(service['corridorWidth']).toBe(2); }); + + it('should read corridor width via ConfigKey', () => { + expect(ConfigKey.Pricing.CorridorWidth).toBe('CORRIDOR_WIDTH'); + expect(configService.get).toHaveBeenCalledWith( + ConfigKey.Pricing.CorridorWidth, + 10, + ); + }); + + it('should default corridor width to 10 when config is missing', async () => { + const emptyConfig = mockDeep(); + emptyConfig.get.mockImplementation( + (_key: string, defaultValue?: unknown) => defaultValue, + ); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MatchingService, + { provide: ConfigService, useValue: emptyConfig }, + { provide: PrismaService, useValue: prismaService }, + { provide: TurfService, useValue: turfService }, + ], + }).compile(); + + expect( + module.get(MatchingService)['corridorWidth'], + ).toBe(10); + }); + + it('should log via Logger and skip trips that fail analysis', async () => { + const candidateTrips = [ + mockTripData, + { ...mockTripData, id: 'trip-456' }, + ]; + const validMatchResult: MatchResult = { + tripId: 'trip-123', + isRequestSent: false, + score: 400, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + }; + + jest + .spyOn(service as any, 'getPreFilteredTrips') + .mockResolvedValue(candidateTrips); + jest + .spyOn(service as any, 'analyzeTrip') + .mockResolvedValueOnce(validMatchResult) + .mockRejectedValueOnce(new Error('Analysis failed')); + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + const freshSession = { packages: [] } as any; + + const result = await service.findMatchedTrips( + mockPackageData, + freshSession, + 20, + prismaService, + ); + + expect(result).toEqual([validMatchResult]); + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith( + 'Error analyzing trip trip-456:', + expect.any(Error), + ); + }); + + it('should delegate scoring to MatchingScorer without drift', () => { + expect(service['calculateMatchingScore'](2000, 2000, true)).toBe(2000); + expect(service['calculateMatchingScore'](100, 100, true)).toBe(0); + }); + + it('should use default maxResults and tx when not provided', async () => { + jest.spyOn(service as any, 'getPreFilteredTrips').mockResolvedValue([]); + + const freshSession = { packages: [] } as any; + const result = await service.findMatchedTrips( + mockPackageData, + freshSession, + ); + + expect(result).toEqual([]); + expect(service['getPreFilteredTrips']).toHaveBeenCalledWith( + mockPackageData, + undefined, + prismaService, + ); + expect(freshSession.packages[0].matchResults).toEqual([]); + }); + + it('should use configured corridor width when analyzeTrip width is omitted', async () => { + const analyzerSpy = jest + .spyOn((service as any).analyzer, 'analyzeTrip') + .mockResolvedValue(null); + + await service['analyzeTrip']( + mockTripData, + mockPackageData.originAddress, + mockPackageData.recipient.address, + ); + + expect(analyzerSpy).toHaveBeenCalledWith( + mockTripData, + mockPackageData.originAddress, + mockPackageData.recipient.address, + 2, + ); + }); }); }); diff --git a/src/modules/package/matching.service.ts b/src/modules/package/matching.service.ts index ef52c2c..db62b46 100644 --- a/src/modules/package/matching.service.ts +++ b/src/modules/package/matching.service.ts @@ -1,6 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { Prisma, TripStatusEnum } from '../../../generated/prisma'; import { Location } from '../map/map.types'; import { ConfigService } from '@nestjs/config'; import { SessionData } from 'express-session'; @@ -11,17 +10,29 @@ import { } from './matching.types'; import { PrismaTransaction } from '../prisma/prisma.types'; import { TurfService } from '../turf/turf.service'; +import { ConfigKey } from '../../common/config/config-names'; +import { MatchingScorer } from './matching/matching-scorer'; +import { CorridorAnalyzer } from './matching/corridor-analyzer'; +import { buildTripCandidateWhere } from './matching/trip-candidate-query'; @Injectable() export class MatchingService { - private corridorWidth: number; + private readonly logger = new Logger(MatchingService.name); + private readonly corridorWidth: number; + private readonly scorer: MatchingScorer; + private readonly analyzer: CorridorAnalyzer; constructor( config: ConfigService, private prisma: PrismaService, - private turfService: TurfService, + turfService: TurfService, ) { - this.corridorWidth = config.get('CORRIDOR_WIDTH', 10); + this.corridorWidth = config.get( + ConfigKey.Pricing.CorridorWidth, + 10, + ); + this.scorer = new MatchingScorer(); + this.analyzer = new CorridorAnalyzer(turfService, this.scorer); } async findMatchedTrips( @@ -61,7 +72,7 @@ export class MatchingService { packageData.recipient.address, this.corridorWidth, ).catch((error) => { - console.error(`Error analyzing trip ${trip.id}:`, error); + this.logger.error(`Error analyzing trip ${trip.id}:`, error); return null; }), ); @@ -100,25 +111,10 @@ export class MatchingService { lastCheckMatching?: Date, tx: PrismaTransaction = this.prisma, ) { - const whereClause: Prisma.TripWhereInput = { - isActive: true, - status: TripStatusEnum.scheduled, - }; - - // Just check new trips after lastCheckMatching - if (lastCheckMatching) { - whereClause.updatedAt = { - gte: lastCheckMatching, - }; - } - - // Filter by weight capacity - if (packageData.weight) { - whereClause.OR = [ - { maxPackageWeightGr: { gte: packageData.weight } }, - { maxPackageWeightGr: null }, - ]; - } + const whereClause = buildTripCandidateWhere({ + weight: packageData.weight, + lastCheckMatching, + }); // TODO: Filter by departure time @@ -161,85 +157,26 @@ export class MatchingService { packageDestination: Location, corridorWidthKm: number = this.corridorWidth, ): Promise { - const tripRoute = this.turfService.createRoute( - trip.origin, - trip.destination, - trip.waypoints, - ); - - const packageOriginPoint = this.turfService.createPoint(packageOrigin); - const packageDestinationPoint = - this.turfService.createPoint(packageDestination); - - // Calculate distances from package points to trip route - const originDistance = this.turfService.getDistanceToRoute( - packageOriginPoint, - tripRoute, + return this.analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + corridorWidthKm, ); - const destinationDistance = this.turfService.getDistanceToRoute( - packageDestinationPoint, - tripRoute, - ); - - // Check if both points are within corridor - const corridorWidthMeters = corridorWidthKm * 1000; - const isOnCorridor = - originDistance <= corridorWidthMeters && - destinationDistance <= corridorWidthMeters; - - if (!isOnCorridor) { - return null; - } - - // Check package and trip are in the same direction. - const isDirectionCompatible = this.turfService.checkDirectionCompatibility( - tripRoute, - packageOriginPoint, - packageDestinationPoint, - ); - - if (!isDirectionCompatible) { - return null; - } - - const score = this.calculateMatchingScore( - originDistance, - destinationDistance, - isOnCorridor, - ); - - return { - tripId: trip.id, - isRequestSent: false, - score, - originDistance, - destinationDistance, - isOnCorridor, - }; } // Note: Score lower is better. - // MVP version + // MVP version — kept as a thin delegate so existing callers/tests keep + // working; new code should use `MatchingScorer` directly. private calculateMatchingScore( originDistance: number, destinationDistance: number, isOnCorridor: boolean, ): number { - // Base score - let score = (originDistance + destinationDistance) / 2; - - // Lower score for packages not on corridor - if (!isOnCorridor) { - score += 100_000; - } - - // Score for trips that start/end very close to package points - if (originDistance < 1000) score -= 500; - if (destinationDistance < 1000) score -= 500; - - // TODO: Add time-based scoring (preferred times) - // TODO: Add transporter rating scoring - - return Math.max(0, score); // Ensure non-negative score + return this.scorer.calculateMatchingScore( + originDistance, + destinationDistance, + isOnCorridor, + ); } } diff --git a/src/modules/package/matching/corridor-analyzer.spec.ts b/src/modules/package/matching/corridor-analyzer.spec.ts new file mode 100644 index 0000000..4341788 --- /dev/null +++ b/src/modules/package/matching/corridor-analyzer.spec.ts @@ -0,0 +1,171 @@ +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { TurfService } from '../../turf/turf.service'; +import { Location } from '../../map/map.types'; +import { TripWithLocations } from '../matching.types'; +import { CorridorAnalyzer } from './corridor-analyzer'; +import { MatchingScorer } from './matching-scorer'; +import { createTripWithLocations } from '../../../../test/fixtures/match.fixture'; +import { createLocation } from '../../../../test/fixtures/location.fixture'; + +describe('CorridorAnalyzer', () => { + let analyzer: CorridorAnalyzer; + let turfService: DeepMockProxy; + + const mockRoute = { + type: 'LineString', + coordinates: [ + ['51.3800', '35.6850'], + ['51.3400', '35.7250'], + ], + }; + + const packageOrigin: Location = createLocation('35.6892', '51.3890'); + const packageDestination: Location = createLocation('35.7219', '51.3347'); + + beforeEach(() => { + turfService = mockDeep(); + analyzer = new CorridorAnalyzer(turfService, new MatchingScorer()); + }); + + function stubTurf( + originDistance: number, + destinationDistance: number, + directionCompatible: boolean, + trip: TripWithLocations = createTripWithLocations(), + ): TripWithLocations { + turfService.createRoute.mockReturnValue(mockRoute as never); + turfService.createPoint.mockReturnValue({ + type: 'Point', + coordinates: [0, 0], + } as never); + turfService.getDistanceToRoute + .mockReturnValueOnce(originDistance) + .mockReturnValueOnce(destinationDistance); + turfService.checkDirectionCompatibility.mockReturnValue( + directionCompatible, + ); + return trip; + } + + describe('analyzeTrip', () => { + it('should return match result for compatible trip', async () => { + const trip = stubTurf(800, 600, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toEqual({ + tripId: trip.id, + isRequestSent: false, + score: 0, + originDistance: 800, + destinationDistance: 600, + isOnCorridor: true, + }); + }); + + it('should return null when origin is outside corridor', async () => { + const trip = stubTurf(3000, 600, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should return null when destination is outside corridor', async () => { + const trip = stubTurf(800, 3000, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should return null when direction is incompatible', async () => { + const trip = stubTurf(800, 600, false); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should create route with waypoints', async () => { + const trip = createTripWithLocations({ + waypoints: [ + createLocation('35.7000', '51.3600'), + createLocation('35.7100', '51.3500'), + ], + }); + stubTurf(800, 600, true, trip); + + await analyzer.analyzeTrip(trip, packageOrigin, packageDestination, 2); + + expect(turfService.createRoute).toHaveBeenCalledWith( + trip.origin, + trip.destination, + trip.waypoints, + ); + }); + + it('should respect custom corridor width', async () => { + const trip = stubTurf(9000, 9000, true); + + const narrow = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + expect(narrow).toBeNull(); + + stubTurf(9000, 9000, true, trip); + const wide = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 10, + ); + expect(wide).not.toBeNull(); + }); + + it('should default to a working scorer when not provided', async () => { + const defaultAnalyzer = new CorridorAnalyzer(turfService); + turfService.createRoute.mockReturnValue(mockRoute as never); + turfService.createPoint.mockReturnValue({ + type: 'Point', + coordinates: [0, 0], + } as never); + turfService.getDistanceToRoute + .mockReturnValueOnce(800) + .mockReturnValueOnce(600); + turfService.checkDirectionCompatibility.mockReturnValue(true); + + const result = await defaultAnalyzer.analyzeTrip( + createTripWithLocations(), + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toMatchObject({ isOnCorridor: true, score: 0 }); + }); + }); +}); diff --git a/src/modules/package/matching/corridor-analyzer.ts b/src/modules/package/matching/corridor-analyzer.ts new file mode 100644 index 0000000..7532e2b --- /dev/null +++ b/src/modules/package/matching/corridor-analyzer.ts @@ -0,0 +1,81 @@ +import { Location } from '../../map/map.types'; +import { TurfService } from '../../turf/turf.service'; +import { MatchResult, TripWithLocations } from '../matching.types'; +import { MatchingScorer } from './matching-scorer'; + +/** + * Corridor + direction analysis for one candidate trip (Phase 4 Task 3). + * + * Pure orchestration over `TurfService` geometry + `MatchingScorer`. + * Extracted verbatim from `MatchingService.analyzeTrip`; returns `null` when + * the trip is not a match (out of corridor or wrong direction). + */ +export class CorridorAnalyzer { + constructor( + private readonly turfService: TurfService, + private readonly scorer: MatchingScorer = new MatchingScorer(), + ) {} + + async analyzeTrip( + trip: TripWithLocations, + packageOrigin: Location, + packageDestination: Location, + corridorWidthKm: number, + ): Promise { + const tripRoute = this.turfService.createRoute( + trip.origin, + trip.destination, + trip.waypoints, + ); + + const packageOriginPoint = this.turfService.createPoint(packageOrigin); + const packageDestinationPoint = + this.turfService.createPoint(packageDestination); + + // Calculate distances from package points to trip route + const originDistance = this.turfService.getDistanceToRoute( + packageOriginPoint, + tripRoute, + ); + const destinationDistance = this.turfService.getDistanceToRoute( + packageDestinationPoint, + tripRoute, + ); + + // Check if both points are within corridor + const corridorWidthMeters = corridorWidthKm * 1000; + const isOnCorridor = + originDistance <= corridorWidthMeters && + destinationDistance <= corridorWidthMeters; + + if (!isOnCorridor) { + return null; + } + + // Check package and trip are in the same direction. + const isDirectionCompatible = this.turfService.checkDirectionCompatibility( + tripRoute, + packageOriginPoint, + packageDestinationPoint, + ); + + if (!isDirectionCompatible) { + return null; + } + + const score = this.scorer.calculateMatchingScore( + originDistance, + destinationDistance, + isOnCorridor, + ); + + return { + tripId: trip.id, + isRequestSent: false, + score, + originDistance, + destinationDistance, + isOnCorridor, + }; + } +} diff --git a/src/modules/package/matching/matching-scorer.spec.ts b/src/modules/package/matching/matching-scorer.spec.ts new file mode 100644 index 0000000..5667800 --- /dev/null +++ b/src/modules/package/matching/matching-scorer.spec.ts @@ -0,0 +1,55 @@ +import { MatchingScorer } from './matching-scorer'; + +describe('MatchingScorer', () => { + const scorer = new MatchingScorer(); + + describe('calculateMatchingScore', () => { + it.each([ + // [originDistance, destinationDistance, isOnCorridor, expected] + [800, 600, true, 0], + [500, 800, true, 0], + [500, 500, true, 0], + [100, 100, true, 0], + [2000, 2000, true, 2000], + [1500, 1500, true, 1500], + [5000, 1000, true, 3000], + [800, 600, false, 99700], + [2000, 2000, false, 102000], + ])( + 'should score (%p, %p, onCorridor=%p) as %p', + (origin, destination, isOnCorridor, expected) => { + expect( + scorer.calculateMatchingScore(origin, destination, isOnCorridor), + ).toBe(expected); + }, + ); + + it('should add penalty for trips not on corridor when far', () => { + const score = scorer.calculateMatchingScore(800, 600, false); + expect(score).toBe(99700); + }); + + it('should apply single close-point bonus when only origin is close', () => { + // (500 + 2000) / 2 - 500 = 750 + expect(scorer.calculateMatchingScore(500, 2000, true)).toBe(750); + }); + + it('should apply single close-point bonus when only destination is close', () => { + // (2000 + 500) / 2 - 500 = 750 + expect(scorer.calculateMatchingScore(2000, 500, true)).toBe(750); + }); + + it('should apply no bonus when both points are far', () => { + expect(scorer.calculateMatchingScore(2000, 3000, true)).toBe(2500); + }); + + it('should ensure non-negative scores', () => { + expect(scorer.calculateMatchingScore(100, 100, true)).toBe(0); + }); + + it('should treat exactly 1000m as not close (strict <)', () => { + // (1000 + 1000) / 2 = 1000, no bonus + expect(scorer.calculateMatchingScore(1000, 1000, true)).toBe(1000); + }); + }); +}); diff --git a/src/modules/package/matching/matching-scorer.ts b/src/modules/package/matching/matching-scorer.ts new file mode 100644 index 0000000..083d33d --- /dev/null +++ b/src/modules/package/matching/matching-scorer.ts @@ -0,0 +1,39 @@ +/** + * Pure matching score calculator (Phase 4 Task 3). + * + * Extracted verbatim from `MatchingService.calculateMatchingScore` so the + * orchestration service can delegate without behavior drift. + * + * Note: lower score is better (MVP rules). + */ +export const MATCHING_OFF_CORRIDOR_PENALTY = 100_000; +export const MATCHING_CLOSE_POINT_THRESHOLD_M = 1000; +export const MATCHING_CLOSE_POINT_BONUS = 500; + +export class MatchingScorer { + calculateMatchingScore( + originDistance: number, + destinationDistance: number, + isOnCorridor: boolean, + ): number { + // Base score: mean distance to route. + let score = (originDistance + destinationDistance) / 2; + + // Penalty for packages not on corridor (unreachable in practice today + // because the analyzer filters first — kept for parity + unit coverage). + if (!isOnCorridor) { + score += MATCHING_OFF_CORRIDOR_PENALTY; + } + + // Bonus for trips that start/end very close to package points. + if (originDistance < MATCHING_CLOSE_POINT_THRESHOLD_M) + score -= MATCHING_CLOSE_POINT_BONUS; + if (destinationDistance < MATCHING_CLOSE_POINT_THRESHOLD_M) + score -= MATCHING_CLOSE_POINT_BONUS; + + // TODO: picks up time-based scoring (preferred times) — MVP gap. + // TODO: picks up transporter rating scoring — MVP gap. + + return Math.max(0, score); // Ensure non-negative score + } +} diff --git a/src/modules/package/matching/trip-candidate-query.spec.ts b/src/modules/package/matching/trip-candidate-query.spec.ts new file mode 100644 index 0000000..5e89188 --- /dev/null +++ b/src/modules/package/matching/trip-candidate-query.spec.ts @@ -0,0 +1,70 @@ +import { TripStatusEnum } from '../../../../generated/prisma'; +import { buildTripCandidateWhere } from './trip-candidate-query'; + +describe('buildTripCandidateWhere', () => { + describe('base filter', () => { + it('should return only active scheduled filter when no input', () => { + expect(buildTripCandidateWhere({})).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + + it('should not filter by weight when weight is null', () => { + expect(buildTripCandidateWhere({ weight: null })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + + it('should not filter by weight when weight is zero', () => { + expect(buildTripCandidateWhere({ weight: 0 })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + }); + + describe('lastCheckMatching', () => { + it('should filter by updatedAt when lastCheckMatching is provided', () => { + const lastCheck = new Date('2024-01-15T10:00:00Z'); + + expect(buildTripCandidateWhere({ lastCheckMatching: lastCheck })).toEqual( + { + isActive: true, + status: TripStatusEnum.scheduled, + updatedAt: { gte: lastCheck }, + }, + ); + }); + }); + + describe('weight capacity', () => { + it('should filter by weight capacity with OR-null when weight is present', () => { + expect(buildTripCandidateWhere({ weight: 5000 })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + OR: [ + { maxPackageWeightGr: { gte: 5000 } }, + { maxPackageWeightGr: null }, + ], + }); + }); + + it('should combine lastCheckMatching and weight filters', () => { + const lastCheck = new Date('2024-01-15T10:00:00Z'); + + expect( + buildTripCandidateWhere({ weight: 2000, lastCheckMatching: lastCheck }), + ).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + updatedAt: { gte: lastCheck }, + OR: [ + { maxPackageWeightGr: { gte: 2000 } }, + { maxPackageWeightGr: null }, + ], + }); + }); + }); +}); diff --git a/src/modules/package/matching/trip-candidate-query.ts b/src/modules/package/matching/trip-candidate-query.ts new file mode 100644 index 0000000..efea4e2 --- /dev/null +++ b/src/modules/package/matching/trip-candidate-query.ts @@ -0,0 +1,40 @@ +import { Prisma, TripStatusEnum } from '../../../../generated/prisma'; + +/** + * Pure pre-filter query builder (Phase 4 Task 3). + * + * Extracted verbatim from `MatchingService.getPreFilteredTrips` so the where + * clause can be unit-tested without Prisma. + */ +export interface TripCandidateQueryInput { + weight?: number | null; + lastCheckMatching?: Date; +} + +export function buildTripCandidateWhere( + input: TripCandidateQueryInput, +): Prisma.TripWhereInput { + const whereClause: Prisma.TripWhereInput = { + isActive: true, + status: TripStatusEnum.scheduled, + }; + + // Just check new trips after lastCheckMatching + if (input.lastCheckMatching) { + whereClause.updatedAt = { + gte: input.lastCheckMatching, + }; + } + + // Filter by weight capacity + if (input.weight) { + whereClause.OR = [ + { maxPackageWeightGr: { gte: input.weight } }, + { maxPackageWeightGr: null }, + ]; + } + + // TODO: picks up departure-time filtering — MVP gap, no behavior change. + + return whereClause; +} diff --git a/src/modules/turf/turf.service.spec.ts b/src/modules/turf/turf.service.spec.ts index 79ad3e7..43c3115 100644 --- a/src/modules/turf/turf.service.spec.ts +++ b/src/modules/turf/turf.service.spec.ts @@ -20,4 +20,51 @@ describe('TurfService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getDistanceToRoute', () => { + const point = { type: 'Point', coordinates: [51.389, 35.6892] } as never; + const route = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [51.38, 35.685], + [51.34, 35.725], + ], + }, + } as never; + + it('should return distance via nearestPointOnLine when it succeeds', () => { + const nearest = { type: 'Point', coordinates: [51.38, 35.685] } as never; + turfProvider.nearestPointOnLine.mockReturnValue(nearest); + turfProvider.distance.mockReturnValue(800 as never); + + expect(service.getDistanceToRoute(point, route)).toBe(800); + expect(turfProvider.nearestPointOnLine).toHaveBeenCalledWith( + route, + point, + ); + }); + + it('should log and fall back to vertex distances when nearestPointOnLine throws', () => { + turfProvider.nearestPointOnLine.mockImplementation(() => { + throw new Error('turf failed'); + }); + // Fake point: mock shapes are not real GeoJSON — assertion needed for tsc. + + turfProvider.point.mockImplementation( + (coords: number[]) => ({ coords }) as never, + ); + turfProvider.distance.mockReturnValue(600 as never); + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + + expect(service.getDistanceToRoute(point, route)).toBe(600); + expect(loggerSpy).toHaveBeenCalledWith( + 'Error calculating distance to route:', + expect.any(Error), + ); + }); + }); }); diff --git a/src/modules/turf/turf.service.ts b/src/modules/turf/turf.service.ts index a19d10a..6af1a5b 100644 --- a/src/modules/turf/turf.service.ts +++ b/src/modules/turf/turf.service.ts @@ -1,10 +1,12 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Feature, LineString, Point } from 'geojson'; import { Turf, TURF_TOKEN } from './turf.provider'; import { Location } from '../map/map.types'; @Injectable() export class TurfService { + private readonly logger = new Logger(TurfService.name); + constructor(@Inject(TURF_TOKEN) private turf: Turf) {} // Helper method for create Point with location @@ -42,7 +44,7 @@ export class TurfService { const nearestPoint = this.turf.nearestPointOnLine(route, point); return this.turf.distance(point, nearestPoint, { units: 'meters' }); } catch (error) { - console.error('Error calculating distance to route:', error); + this.logger.error('Error calculating distance to route:', error); return this.getDistanceToRouteSimple(point, route); } } diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index 92979ba..305c3b9 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -8,3 +8,4 @@ export * from './user.fixture'; export * from './session.fixture'; export * from './location.fixture'; +export * from './match.fixture'; diff --git a/test/fixtures/match.fixture.ts b/test/fixtures/match.fixture.ts new file mode 100644 index 0000000..480f94b --- /dev/null +++ b/test/fixtures/match.fixture.ts @@ -0,0 +1,68 @@ +import { + MatchResult, + PackageWithLocations, + TripWithLocations, +} from '../../src/modules/package/matching.types'; +import { TripStatusEnum } from '../../generated/prisma'; +import { + DESTINATION_LOCATION, + ORIGIN_LOCATION, + createLocation, +} from './location.fixture'; + +/** Fresh TripWithLocations per call — never mutate the shared constant. */ +export const createTripWithLocations = ( + overrides: Partial = {}, +): TripWithLocations => ({ + id: 'trip-123', + status: TripStatusEnum.scheduled, + origin: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + destination: createLocation( + DESTINATION_LOCATION.latitude, + DESTINATION_LOCATION.longitude, + ), + waypoints: [], + ...overrides, +}); + +export const mockTripWithLocations = createTripWithLocations(); + +/** Fresh MatchResult per call. */ +export const createMatchResult = ( + overrides: Partial = {}, +): MatchResult => ({ + tripId: 'trip-123', + isRequestSent: false, + score: 0, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + ...overrides, +}); + +export const mockMatchResult = createMatchResult(); + +/** Fresh PackageWithLocations per call (cast: shares shape with Prisma Package). */ +export const createPackageWithLocations = ( + overrides: Partial = {}, +): PackageWithLocations => + ({ + id: 'package-123', + weight: 2000, + originAddress: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + recipient: { + address: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + }, + ...overrides, + }) as unknown as PackageWithLocations; + +export const mockPackageWithLocations = createPackageWithLocations(); From adf64a9535fed990e965ec83c564178914446391 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Sat, 3 Oct 2026 23:42:33 +0330 Subject: [PATCH 5/5] chore: untrack docs/ on feature branch (docs stay on dev; as-built in commit messages) --- .gitignore | 3 +- docs/BREAKING-CHANGES.md | 59 ------ docs/CODEBASE-AUDIT.md | 242 ------------------------ docs/CONVENTIONS.md | 79 -------- docs/INDEX.md | 72 ------- docs/REFACTORING-PLAN.md | 159 ---------------- docs/TESTING-STRATEGY.md | 91 --------- docs/phase-1-foundation.md | 107 ----------- docs/phase-2-security-config.md | 220 --------------------- docs/phase-3-auth-session.md | 183 ------------------ docs/phase-4-core-business.md | 294 ----------------------------- docs/phase-5-supporting-modules.md | 128 ------------- docs/phase-6-integration-ci.md | 117 ------------ 13 files changed, 2 insertions(+), 1752 deletions(-) delete mode 100644 docs/BREAKING-CHANGES.md delete mode 100644 docs/CODEBASE-AUDIT.md delete mode 100644 docs/CONVENTIONS.md delete mode 100644 docs/INDEX.md delete mode 100644 docs/REFACTORING-PLAN.md delete mode 100644 docs/TESTING-STRATEGY.md delete mode 100644 docs/phase-1-foundation.md delete mode 100644 docs/phase-2-security-config.md delete mode 100644 docs/phase-3-auth-session.md delete mode 100644 docs/phase-4-core-business.md delete mode 100644 docs/phase-5-supporting-modules.md delete mode 100644 docs/phase-6-integration-ci.md diff --git a/.gitignore b/.gitignore index ecbd0fb..da5cff9 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -/generated \ No newline at end of file +/generated +/docs \ No newline at end of file diff --git a/docs/BREAKING-CHANGES.md b/docs/BREAKING-CHANGES.md deleted file mode 100644 index b623fad..0000000 --- a/docs/BREAKING-CHANGES.md +++ /dev/null @@ -1,59 +0,0 @@ -# Breaking Changes & Frontend Coordination Log - -> Living document. Every API-visible change made during the refactor is recorded here with before/after details so the frontend (hambaar-app/frontend) can be updated in sync. - -**Format:** each entry lists affected endpoints, the change, and required frontend action. - ---- - -## Phase 1 — Foundation (no API changes) - -Phase 1 touches tooling, tests, and internal helpers only. - -| # | Change | API-visible? | Frontend action | -|---|---|---|---| -| 1.1 | Removed `moment` dependency; `getDateDifference` (used by dashboard "age" strings) now uses `date-fns` calendar math | No (internal formatting; output format unchanged: `X سال و Y ماه`) | None | -| 1.2 | OTP `generateCode` range fix is **not** applied in Phase 1 (deferred to Phase 3 with its own entry) | — | — | - ---- - -## Phase 2 — Bootstrap, Config & Security (applied) - -| # | Change | API-visible? | Frontend action | -|---|---|---|---| -| 2.1 | CORS now requires explicit `CORS_ORIGINS` — requests from unlisted origins are blocked (default: all blocked) | Yes | Provide frontend origin(s) to backend env | -| 2.2 | Validation 400s: unknown body fields now rejected (`forbidNonWhitelisted`) | Yes | Do not send fields not in the DTOs | -| 2.3 | `API_PREFIX` (default `api`) — all routes now under `/api/…` | Yes | Update API base URL | -| 2.4 | Swagger disabled in production | No | (n/a) | -| 2.5 | Global rate limiter returns 429 beyond `THROTTLE_LIMIT` per `THROTTLE_TTL` | Yes | Respect 429 + `Retry-After` | - ---- - -## Pending (specified in phase docs, not yet applied) - -Each remaining phase doc contains its own detailed breaking-changes section; the consolidated frontend digest is finalized in Phase 6. Headline items already specified: -- **Phase 4 (4.1):** tracking endpoint drops/masks `sender.phoneNumber` — **pending** (Task 5 not started). -- **Phase 5 (5.1–5.2):** support verification 404 when status missing; upload size limit enforced. - -> Rule: entries move from "pending (specified)" to "applied" **with the phase that lands them**. - ---- - -## Phase 4 — Core Business (partial: Task 1 + Task 2, no API change) - -| # | Change | API-visible? | Frontend action | -|---|---|---|---| -| 4.0 | `TransactionRunner` + pricing engine/strategies refactor landed 2026-09-04 — pricing output byte-identical by golden parity, runner internal only | No | None | -| 4.1 | Tracking PII mask (`sender.phoneNumber`) — **not yet applied** | — (pending) | — | -| 4.2 | Pricing byte-identical guarantee — holds for the partial landing | No | None — report any drift as a bug | - ---- - -## Phase 3 — Auth, Session & User Domain (applied) - -| # | Change | API-visible? | Frontend action | -|---|---|---|---| -| 3.1 | OTP is now **6 digits** (was 5) and zero-padded; `code` in `POST /auth/check-otp` is now a 6-digit **string** (was a 5-digit number) | Yes | Update SMS/template expectations; OTP input UI must accept 6 digits; send `code` as string | -| 3.2 | `JWT_*_EXPIRES_IN` envs now configurable — defaults unchanged (`20d/20m/1d`) | No (only if envs changed) | none | -| 3.3 | Session `userState` write for vehicle registration happens inside the service (same end state) | No (behavior parity) | none | -| 3.4 | Guard files re-exports only — no API change | No | none | diff --git a/docs/CODEBASE-AUDIT.md b/docs/CODEBASE-AUDIT.md deleted file mode 100644 index 1a799a1..0000000 --- a/docs/CODEBASE-AUDIT.md +++ /dev/null @@ -1,242 +0,0 @@ -# Hambaar Backend — Codebase Audit - -> **Date:** 2026-09-02 · **Baseline commit:** `ae11d31` (master) · **Scope:** full `src/`, `prisma/`, CI, Docker, tooling -> -> This document is the evidence-based foundation of the refactoring plan (`REFACTORING-PLAN.md`). -> Severity legend: 🔴 Critical · 🟠 High · 🟡 Medium · 🔵 Low / hygiene - ---- - -## 1. Project Snapshot - -| Item | Value | -|---|---| -| Framework | NestJS 11, Express 5, TypeScript 5.7 | -| ORM / DB | Prisma 6 + PostgreSQL (`prisma db push`, **no migration history**) | -| Cache / Session | Redis 5.x (`cache-manager` + `@keyv/redis`, `express-session` + `connect-redis`) | -| Storage | AWS S3 (pre-signed URLs) | -| External APIs | Neshan Maps, SMS (s.api.ir) | -| Source size | ~153 TS files, 17 feature modules | -| Tests | ~40 `*.spec.ts` files (shallow), 1 trivial e2e spec | - -**Module inventory:** app, auth, user, vehicle, token, s3, sms, address, package, trip, map, financial, support, notification, dashboard, pricing, turf, health, prisma. - ---- - -## 2. Architecture Issues - -### A-1 🔴 God services (business logic monoliths) -| File | Size | Injected deps | Problems | -|---|---|---|---| -| `src/modules/trip/trip.service.ts` | **32.6 KB** | 6 | CRUD + request lifecycle + tracking + rating + financial escrow + S3 + geo + notifications in one class | -| `src/modules/package/package.service.ts` | **22.2 KB** | 8 | Recipients, items, pricing, matching orchestration, requests, cancellation, tracking — one class | -| `src/modules/auth/auth.service.ts` | **14.9 KB** | 8 | OTP, signup, documents, session state machine — one class | - -Controllers receive raw Prisma models; services contain business rules, persistence queries, external-API calls, session mutation, and notification side effects simultaneously. - -### A-2 🔴 Layering violations -- Controllers receive raw Prisma entities (no response mapping consistently; `instanceToPlain`/`plainToInstance` used ad-hoc). -- `MapService` (infrastructure client) depends on `PrismaService` and performs DB lookups (`getIntermediateCitiesWithIds`) — infra layer reaching into the data layer. -- `SerializeInterceptor` exists (`src/common/serialize.interceptor.ts`) but is used in only one place (`health.controller.ts`); the rest of the API returns unfiltered entity objects (internal-field exposure risk). - -### A-3 🟠 Repeated transaction/error boilerplate -Every service repeats: -```ts -this.prisma.$transaction(async tx => { ... }) - .catch((error: Error) => { formatPrismaError(error); throw error; }); -``` -and every `find*OrThrow` call wraps the same `.catch(formatPrismaError)` — dozens of duplicated blocks. `formatPrismaError` is declared to return `never` and always throws, yet every caller still does `throw error` after it (unreachable code). - -### A-4 🟠 Session-as-database anti-pattern -Matching results, packages-in-progress, and user state are stored in **express-session** (`session.packages[].matchResults[]`, `session.userState`) — e.g. `matching.service.ts`, `package.service.ts#cancelRequest`, `auth.service.ts#getUserState`. Session mutations are not transactional with DB writes; `cancelRequest` mutates the session inside a Prisma transaction (Redis store is outside the tx — inconsistency risk). - ---- - -## 3. Security Issues - -### S-1 🔴 CORS reflects any origin with credentials -`src/main.ts:16-19`: `app.enableCors({ origin: true, credentials: true })` — mirrors the request origin; combined with cookie-based auth (`AccessToken`, `SessionId` cookies) any website can make credentialed requests. - -### S-2 🔴 Session cookie `secure: false` in all environments -`src/modules/app/app.module.ts:94` — with an explicit `// TODO: Fix this when you deploy on a https server`. - -### S-3 🟠 Access token lifetime: 20 days, no refresh/rotation -`src/modules/token/token.service.ts:27` — `expiresIn: '20d'`, no rotation mechanism, no server-side revocation. - -### S-4 🟠 No per-IP rate limiting -Only OTP-level attempt throttling exists (`AuthService.checkIfBlocked`). `sendOtp` and other SMS-triggering endpoints have no `ThrottlerGuard`/IP limits — SMS-bombing risk with direct cost. - -### S-5 🟠 No security headers; Swagger unauthenticated in prod -No `helmet`/CSP; Swagger UI exposed at `/docs` in production. - -### S-6 🟡 OTP entropy bias -`src/common/utilities.ts:20` — `crypto.randomInt(11_111, 99_999)`: 5-digit numeric OTP where the first digit can never be 0 or 1 (~11% of the code space excluded) and codes are not zero-padded to a fixed width. - -### S-7 🟡 `generateUniqueCode` weakness -`src/common/utilities.ts:24` — `Date.now().toString() + crypto.randomInt(1_111_111, 9_999_999)` — not guaranteed unique (no uniqueness check demonstrated), biased numeric range. - -### S-8 🟡 Tracking endpoint leaks sender phone number -`package.service.ts#getTrackingByCode` returns `sender.phoneNumber` to anyone holding a tracking code (route has no auth guard) — PII exposure. - ---- - -## 4. Configuration & Bootstrap Issues - -### C-1 🔴 Env config bug: trailing space in key -`src/modules/auth/auth.service.ts:50`: -```ts -this.maxSendAttempts = config.get('MAX_SEND_ATTEMPTS ', 5); -``` -The env key `'MAX_SEND_ATTEMPTS '` has a **trailing space** — the real env var is never read; the default (5) silently applies. There is **no config validation schema**, so this class of bug is undetectable at boot. - -### C-2 🔴 Redis client created inside `AppModule.configure()` -`src/modules/app/app.module.ts:74-99`: -- `configure()` is declared `async` but Nest does not await it — the session middleware may attach **after** the app starts listening (race). -- Bootstrap serializes on Redis availability with no error handling/retry. -- Session Redis client lifecycle is unmanaged (no shutdown/`client.quit()`). - -### C-3 🟠 Fragile positional cache store access -`auth.service.ts:47`: `this.cacheManager = cacheManager.stores[1];` — silently breaks if the store order in `app.module.ts` changes; no named-store abstraction. - -### C-4 🟠 Missing env validation + config drift -`ConfigModule.forRoot` has no `validationSchema`; every `config.get(..., default)` silently masks missing/typo'd variables (see C-1). Pricing defaults in code **differ from** README docs (e.g. `PRICING_WEIGHT_BASE_RATE` default 10000 vs README 8000; `PRICING_PLATFORM_COMMISSION` env var documented but unused — commission derived from `driverShare`). - -### C-5 🟡 Bootstrap hygiene (`src/main.ts`) -- No global prefix / versioning (`/api/v1`). -- No graceful shutdown (`app.enableShutdownHooks()` absent). -- `bootstrap()` not awaited / no top-level catch → unhandled rejection on startup failure. -- No compression; PORT not validated. - -### C-6 🟡 Health check depends on an external website -`health.controller.ts:37` — `http.pingCheck('nestjs-docs', 'https://docs.nestjs.com')`: production readiness depends on nestjs.com and generates outbound traffic per poll; memory heap threshold hardcoded. - -### C-7 🔵 Prisma managed by `db push`; `prisma/.env` committed -`prisma db push` runs on every `start:dev` and on every container start (`entrypoint.sh`); no migration history; `prisma/.env` (with DB URL) is committed. - ---- - -## 5. Code Smells & Correctness Risks - -### B-1 🔴 Private method accessed via bracket notation -`auth.service.ts:371`: `this.tokenService['generateAccessToken'](payload)` — TypeScript visibility deliberately bypassed; hides the real public API of `TokenService`. - -### B-2 🟠 `request.user` mutation order bug risk -Guards assign `request.user = { id: payload.sub, ...request.user }` (`token.guard.ts:50-53, 79-82`) — the spread **after** means an existing `request.user.id` (if any) overwrites the verified `payload.sub`. `CurrentUser` decorator reads `request.user?.[key]` with a string key — no type safety. - -### B-3 🟠 Duplicated guard logic -`TemporaryTokenGuard`, `ProgressTokenGuard`, `AccessTokenGuard`, `MultiTokenGuard`, `DenyAuthorizedGuard` share ~80% of their logic (cookie extract → verify → compare to session → set user) with divergent error messages and subtly different payload checks. - -### B-4 🟡 Silent failure swallowing + console logging -`matching.service.ts:55-58` — `analyzeTrip(...).catch(error => { console.error(...); return null; })` inside `Promise.allSettled`; `console.error/warn` used across `map.service.ts`, `sms.service.ts`, `utilities.ts`. No Nest `Logger` anywhere, no structured logging, no request correlation IDs. - -### B-5 🟡 Commented-out / dead code -`sms.service.ts:46-50` (commented OTP message), `token.service.ts:26,31,36` (pointless local aliases), commented `secure` cookie line, `// TODO` markers (payment gateway, departure-time filter, waypoint sorting, S3 upload limit). - -### B-6 🟡 Mixed date libraries -Both `moment` (legacy) and `date-fns` are dependencies; `moment` is used only in `utilities.ts#getDateDifference` — replaceable with `date-fns` (also calendar-inaccurate `asMonths() % 12` math). - -### B-7 🟡 Dependency hygiene -- `jest-mock-extended` is a **production dependency** (belongs in devDependencies). -- `cookie-parser` middleware is applied **after** the session middleware (`app.module.ts:101-104`) — parser should run before anything that reads cookies. -- `prisma/.env` committed to the repository. - -### B-11 🔴 (found during Phase 1) No ESLint configuration at all -The repo has **no ESLint config file** (no `eslint.config.*` / `.eslintrc.*`), so `npm run lint` fails — linting has never been enforced despite the CI narrative. Fixed in Phase 1 with a type-checked flat config. - -### B-12 🟠 (found during Phase 1) Test suite runtime & teardown leaks -Baseline `npm run test:cov` took **514 s** on a dev machine with worker force-exit warnings ("worker process has failed to exit gracefully") — ts-jest full type-checking per file plus leaked handles. Fixed in Phase 1 via `isolatedModules` transform (28 s) — type safety is enforced by `tsc --noEmit` and the new ESLint type-checked config instead. - -### B-8 🟡 Untyped helpers -`toPersianDigits(number)` has no type annotation; `getDateDifference` approximates calendar math; `utilities.ts` mixes concerns (crypto codes, Prisma error mapping, validators, formatting) in one 7.3 KB file. - -### B-9 🔵 Internal Prisma runtime import -`package.service.ts:16` — `import { JsonArray } from '../../../generated/prisma/runtime/library'` — internal path, breaks across Prisma versions. - -### B-10 🔵 Pricing tier arithmetic fragile -`pricing.service.ts:141` — `tier.maxKm - tier.minKm + (tier.minKm ? 1 : 0)` correct only for the current 1-based inclusive bounds; silently wrong if tier bounds change. - ---- - -## 6. Testing Issues - -### T-1 🔴 Coverage unmeasured and unenforced -`package.json#jest`: `collectCoverageFrom: ["**/*.(t|j)s"]` with `rootDir: "."` — coverage sweeps the entire repo; **no `coverageThreshold` at all**; CI runs `npm test` without coverage. - -### T-2 🔴 Controller tests assert nothing meaningful -Every `*.controller.spec.ts` mocks the service with `jest-mock-extended` and verifies that a method that just delegates returns its mock verbatim. Guards are stubbed with `canActivate: () => true`. These tests verify wiring, not behavior. - -### T-3 🟠 No tests for the highest-risk logic -Zero unit tests for: all guards, `token.service.ts`, `utilities.ts` (`formatPrismaError` — the app-wide error mapping!), validators (`IsValidS3Key`, datetime tuples), `serialize.interceptor.ts`, `matching.service.ts` scoring, `map.service.ts` parsing, `s3.service.ts`, `sms.service.ts`. - -### T-4 🟠 Inconsistent test styles -Some specs use `Test.createTestingModule` + `DeepMockProxy`, others hand-rolled `jest.fn()` objects (`auth.service.spec.ts` builds a manual `keyvMock`); no fixture builders; magic mock data inline. - -### T-5 🟡 e2e suite is the scaffold default -`test/app.e2e-spec.ts` asserts `getHello()` returns "Hello World!" — dead weight. - -### T-6 🟡 CI unit tests run with all prod secrets set -`test-build.yml` exposes AWS/JWT/session secrets to the unit-test job — masks accidental secret/network usage and violates least privilege. - ---- - -## 7. Observability & Ops - -- **O-1 🟠** No structured logger: `console.*` everywhere; no request-ID/correlation; Nest `Logger` unused. -- **O-2 🟡** No metrics/tracing; health endpoint only. -- **O-3 🟡** `Dockerfile`/`entrypoint.sh` run `prisma db push` + seeds on **every container start** (prod image mutates DB schema at boot, seeds via `concurrently`). -- **O-4 🔵** `.dockerignore` present (good); `dist/` exists locally (verify `.gitignore`). - ---- - -## 8. Positive Findings (keep) - -- Clean NestJS module folder boundaries; global `ValidationPipe` with `whitelist`/`transform`. -- `formatPrismaError` centralizes Prisma → HTTP error mapping (right concept, wrong ergonomics). -- OTP rate-limiting state machine (send/check attempts, block windows) is a thoughtful feature. -- Pre-signed URL upload flow with S3-key pattern validation (`IsValidS3Key`). -- `OwnershipGuard` with Reflector metadata; Turf.js corridor matching is a solid domain concept. -- Redis-backed sessions in prod; Docker + CI pipeline exists. - ---- - -## 9. Baseline Metrics - -*Filled after the Phase 1 baseline coverage run (see `TESTING-STRATEGY.md` for how it is reproduced).* - -| Metric | Baseline (2026-09-02) | Phase 6 Target | -|---|---|---| -| Statements % | 68.13 | 100% | -| Functions % | 41.80 | 100% | -| Lines % | 67.48 | 100% | -| Branches % | 59.82 | ≥ 95% | -| CI coverage gate | none | enforced | -| ESLint strict type-checked | no (added in Phase 1) | yes (0 errors) | - ---- - -## 10. Issue → Phase Mapping - -| Issue(s) | Phase | -|---|---| -| T-1…T-6, B-11, B-12 | Phase 1 | -| C-1…C-7, S-1…S-5, B-5, B-7 (cookie-parser order), O-1, O-3 | Phase 2 | -| A-1 (auth), A-3, A-4, B-1, B-2, B-3, S-6, S-7 | Phase 3 | -| A-1 (package/trip), A-2, A-3, A-4, B-4, B-9, B-10, S-8 | Phase 4 | -| remaining modules, infra adapters (S3/SMS/Maps) | Phase 5 | -| O-2, integration tests, final gates | Phase 6 | - ---- - -## 11. Decision Log (from product owner) - -| Decision | Choice | -|---|---| -| Backward compatibility | Mostly compatible; documented breaking changes allowed (frontend can be updated) | -| Database | Keep `prisma db push` and current schema; **application code only** | -| Refactor depth | Full professional restructure (domain layer, thin controllers, infra adapters, strategies) | -| Coverage policy | 100% statements/functions/lines, ≥95% branches, enforced by Jest thresholds + CI | -| Test types | Unit tests (fully mocked) + integration tests on real Dockerized Postgres/Redis in CI | - - - - diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md deleted file mode 100644 index 88e7214..0000000 --- a/docs/CONVENTIONS.md +++ /dev/null @@ -1,79 +0,0 @@ -# Conventions — Target Architecture & Coding Rules - -> Applies to all refactoring phases. New code must follow this document; touched code is migrated to it. - -## 1. Folder & Naming - -| Kind | Convention | Example | -|---|---|---| -| Folders | `kebab-case` | `trip-request.service.ts` location: `modules/trip/domain/` | -| Classes | `PascalCase`, suffixed by role | `TripRequestService`, `S3StorageAdapter` | -| DTOs | `*Dto` in `dto/` of the owning module | `CreateTripDto` | -| Interfaces/tokens | `*Port` for infra interfaces, injection token `'PORTS.SMS'` | `SmsPort` | -| Test files | `.spec.ts` co-located with source | `token.service.spec.ts` | -| Fixtures | `test/fixtures/*.fixture.ts` (shared, pure factories) | `user.fixture.ts` | -| Enums | single-purpose enum files under `common/enums/` | `cookies.enum.ts` | - -**Module layout (business modules with real logic):** -``` -/ -├── .module.ts -├── .controller.ts # thin HTTP only -├── application/ # orchestration services -├── domain/ # business rules / state machines -├── dto/ -└── types/ -``` - -## 2. Layering Rules - -1. **Controllers** — no injected `PrismaService`, no business rules, no S3/SMS/HTTP calls. Read params/DTO → call one application service → return mapped DTO. -2. **Application services** — own use-cases; may open transactions through `TransactionRunner`; compose domain services; never import Prisma types into their public signatures (use domain types). -3. **Domain services** — pure logic (state machines, pricing, scoring). They receive plain inputs; no `ConfigService` where a value can be constructor-injected as a plain option. -4. **Infra adapters (`src/infra/**`)** — the only place touching S3, SMS HTTP APIs, Neshan, raw Redis clients. Each implements an interface (`*Port`) and is bound via an injection token, so unit tests replace it with a fake. -5. **No cross-module imports of internals** — modules expose what others need via their module exports (services/tokens), never deep-import `application/domain` files of another module. -6. **Prisma client access** — only through `PrismaService` or `TransactionRunner`; `PrismaTransaction` type alias is the single transaction handle type. - -## 3. Error Handling - -- A single global `AllExceptionsFilter` maps exceptions to the standard error envelope; an internal - `PrismaErrorMapper` converts Prisma errors — services stop wrapping every call in `.catch(formatPrismaError)`. -- `formatPrismaError` is retired in favor of the filter; the mapping table (P2002 → 409, P2025 → 404, …) is preserved and unit-tested via the filter. -- Services throw typed Nest exceptions or domain exceptions; no `console.error` for control flow. -- Error response envelope (single shape for all errors): - -```json -{ - "statusCode": 409, - "message": "A Vehicle with the plate already exists.", - "error": "Conflict" -} -``` - -## 4. Validation & Serialization - -- All request bodies/params/queries validated by DTOs (`class-validator`) through the global `ValidationPipe` with `whitelist`, `transform`, `forbidNonWhitelisted`. -- Responses exposed through `@Serialize(Dto)` with `@Expose()`-annotated DTOs — never raw entities. -- Config: every env var declared in a validated schema (`src/common/config/env.validation.ts`); access via typed `ConfigService` getters or injected option objects — **no stringly-typed keys scattered in services**. - -## 5. Testing Conventions - -- One spec per production file; arrange-act-assert; `describe` per method. -- Mocking: `jest-mock-extended` for class dependencies; hand-written fakes for infra ports. -- Fixtures from `test/fixtures/` — no inline 20-line mock literals. -- No test requires env vars, DB, Redis, or network. Time-dependent tests use `jest.useFakeTimers()` / injected clock. -- Naming: `it('should when ')`. - -## 6. TypeScript & Lint - -- `strict: true` (tsconfig), `noUncheckedIndexedAccess` aspirational for new files. -- ESLint flat config with `typescript-eslint` recommended-type-checked; rules enabled in Phase 1: - `@typescript-eslint/no-explicit-any` (warn→error per phase), `no-floating-promises`, `no-misused-promises`, `no-unsafe-argument`, `no-unused-vars` (error), `eqeqeq`. -- Prettier: existing `.prettierrc` (singleQuote, trailingComma all) + `printWidth: 100`. -- No bracket-notation access to private members; no `as any` without a comment and a `// eslint-disable` line. - -## 7. Git & CI - -- Conventional Commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`) — one phase ≈ one PR series. -- CI stages: lint → unit tests (no secrets) → coverage gate → build (on master). Integration stage added in Phase 6. -- `prisma/.env` removed from the repo; `.env.development` remains as the documented template. diff --git a/docs/INDEX.md b/docs/INDEX.md deleted file mode 100644 index eb603c5..0000000 --- a/docs/INDEX.md +++ /dev/null @@ -1,72 +0,0 @@ -# Hambaar Backend — Documentation Index - -> Complete documentation set for the **"Professional Refactor"** (started 2026-09-02). -> All files live in `docs/` and are updated as phases progress. - -## How to read this set - -`REFACTORING-PLAN.md` is the **master plan** (goals, phases, decisions, risks). -`CODEBASE-AUDIT.md` is the **evidence** (every issue, with severity and file refs). -`CONVENTIONS.md` is the **law** (target architecture, coding rules) for all new code. -`TESTING-STRATEGY.md` is the **coverage & test policy** used to grade every phase. -`BREAKING-CHANGES.md` is the **frontend coordination log** (every API-visible change). -One `phase-N-*.md` file per phase — the **implementation spec + as-built record**. - -## Document map - -| Doc | What it answers | Status | -|---|---|---| -| [REFACTORING-PLAN.md](./REFACTORING-PLAN.md) | What are we doing, in what order, and why | ✅ | -| [CODEBASE-AUDIT.md](./CODEBASE-AUDIT.md) | What is wrong right now (evidence) | ✅ | -| [CONVENTIONS.md](./CONVENTIONS.md) | How should the target code look | ✅ | -| [TESTING-STRATEGY.md](./TESTING-STRATEGY.md) | How do we prove quality (coverage policy) | ✅ | -| [BREAKING-CHANGES.md](./BREAKING-CHANGES.md) | What will the frontend need to change | ✅ | -| [phase-1-foundation.md](./phase-1-foundation.md) | Phase 1 spec + as-built | ✅ Completed | -| [phase-2-security-config.md](./phase-2-security-config.md) | Phase 2 spec + as-built | ✅ Completed | -| [phase-3-auth-session.md](./phase-3-auth-session.md) | Phase 3 spec + as-built | ✅ Completed | -| [phase-4-core-business.md](./phase-4-core-business.md) | Phase 4 spec + partial as-built + **Phases 1–4 rollup (§10)** | 🚧 In progress (Tasks 1–3 landed) | -| [phase-5-supporting-modules.md](./phase-5-supporting-modules.md) | Phase 5 spec (modules + infra adapters) | ✅ Spec complete | -| [phase-6-integration-ci.md](./phase-6-integration-ci.md) | Phase 6 spec (integration, CI, final gates) | ✅ Spec complete | - -## Quick stats - -| Metric | Value | -|---|---| -| Total audit issues | ~60 (🔴 12 · 🟠 20 · 🟡 20 · 🔵 8) | -| Phases | 6 | -| Docs in set | 12 | -| Coverage baseline (scoped) | 68 / 60 / 42 / 67 (Stmts/Branches/Funcs/Lines) | -| Coverage target | 100 / 95 / 100 / 100 | -| Suite runtime | 514 s → ~20 s | - -## Phase dependency graph - -``` -Phase 1 (foundation/tooling) ──► Phase 2 (bootstrap/config/security) - │ - ▼ - Phase 3 (auth/session/user) - │ - ▼ - Phase 4 (package/trip/pricing/matching) - │ - ▼ - Phase 5 (supporting modules + infra adapters) - │ - ▼ - Phase 6 (integration tests, CI gates, final docs) -``` - -Rationale: infrastructure first (everything depends on bootstrap/config), then auth (everything depends on auth), then the two god services, then the long tail, then locking it all in with integration + CI. - -## Changelog - -| Date | Change | -|---|---| -| 2026-09-02 | Full doc set created (audit, plan, conventions, testing, breaking-changes, index, all 6 phase docs). Phase 1 as-built filled. Baseline metrics back-filled from scoped coverage run. | -| 2026-09-03 | Phase 2 + Phase 3 landed (`cfb158a`, `ea84577`); Phase 3 as-built filled; BREAKING-CHANGES 2.x–3.x applied. | -| 2026-09-04 | Docs-only review: Phase 2 marked Completed; Phase 4 partial (runner + pricing) recorded with Phases 1–4 rollup in `phase-4-core-business.md` §10; root `phase-1-foundation.md` removed (docs/ is canonical); `/docs` removed from `.gitignore` (was untracked since `2c50a47`) so docs are committable. Verified: `tsc` clean, pricing+prisma 10 suites / 98 tests green, lint 17 errors / 236 warnings tracked as debt. | - -## Updating policy -- Any change to conventions/architecture goes through `REFACTORING-PLAN.md` and `CONVENTIONS.md`. -- Any API-visible change must add a `BREAKING-CHANGES.md` entry **before** it lands. \ No newline at end of file diff --git a/docs/REFACTORING-PLAN.md b/docs/REFACTORING-PLAN.md deleted file mode 100644 index 6dd1c0b..0000000 --- a/docs/REFACTORING-PLAN.md +++ /dev/null @@ -1,159 +0,0 @@ -# Hambaar Backend — Refactoring Master Plan - -> **Status:** 🚧 In progress · **Started:** 2026-09-02 · **Baseline:** `ae11d31` -> -> See [INDEX.md](./INDEX.md) for the document map, [CODEBASE-AUDIT.md](./CODEBASE-AUDIT.md) for the full evidence base, [CONVENTIONS.md](./CONVENTIONS.md) for target architecture rules, [TESTING-STRATEGY.md](./TESTING-STRATEGY.md) for the coverage policy, and [BREAKING-CHANGES.md](./BREAKING-CHANGES.md) for API changes frontend must adopt. - ---- - -## 1. Goals - -1. **Professional architecture:** domain/business logic separated from persistence and infrastructure; thin controllers; explicit service boundaries; strategies for pricing & matching; global pipes/filters/interceptors instead of copy-pasted error handling. -2. **100% test confidence:** re-implement the entire unit test suite; enforce **100% statements / functions / lines, ≥95% branches** via Jest thresholds and the CI pipeline. -3. **Security & correctness:** fix the identified 🔴/🟠 issues (CORS, cookie flags, config validation, session bootstrap race, guard duplication, PII exposure). -4. **Zero schema changes:** Prisma schema and `db push` workflow stay untouched. -5. **Complete documentation:** every phase ships a phase doc; every breaking change is recorded for the frontend team. - -## 2. Non-Goals - -- Database schema redesign, migrations, or data changes. -- Changing deployment target (Liara + Docker Hub pipeline stays). -- New features (payment gateway etc. remain TODOs — documented, not implemented). - -## 3. Constraints & Ground Rules - -| Rule | Detail | -|---|---| -| App stays working | Every phase ends with a green build and a passing test suite; phases are independently shippable. | -| API stability | Existing endpoints keep their contracts unless listed in `BREAKING-CHANGES.md`. | -| No schema changes | Prisma schema files are read-only during the refactor. | -| Coverage gate | From Phase 1 onward, coverage is measured; the hard gate is enforced per-module as each phase completes (global gate switched on in Phase 6). | -| Test style | Unit tests are fully mocked (no DB/Redis/network); integration tests (Phase 6) use Dockerized Postgres/Redis in CI. | -| Secrets | Unit tests must never require env secrets or network access. | - ---- - -## 4. Phase Overview - -| # | Phase | Focus | Doc | -|---|---|---|---| -| 1 | Foundation & Test Infrastructure | Jest config, coverage baseline, test utilities, deps hygiene, docs | [phase-1-foundation.md](./phase-1-foundation.md) | -| 2 | Bootstrap, Config & Security | main.ts, config schema, session, guards consolidation, filters/interceptors | [phase-2-security-config.md](./phase-2-security-config.md) | -| 3 | Auth, Session & User Domain | OTP service, auth state machine, signup split, token API | [phase-3-auth-session.md](./phase-3-auth-session.md) | -| 4 | Core Business (Package/Trip/Matching/Pricing/Turf) | Split god services, pricing strategy, transaction runner | [phase-4-core-business.md](./phase-4-core-business.md) | -| 5 | Supporting Modules & Infra Adapters | vehicle, address, financial, map, s3, sms, support, dashboard, health | [phase-5-supporting-modules.md](./phase-5-supporting-modules.md) | -| 6 | Integration Tests, CI & Final Docs | Dockerized integration suite, CI gates, README, completion | [phase-6-integration-ci.md](./phase-6-integration-ci.md) | - -### Phase sequencing rationale -Phases are ordered by blast radius: infrastructure first (everything depends on bootstrap/config), then the auth stack (everything depends on auth), then the two god services, then the long tail. Tests are re-implemented **within the phase that touches the code**, so behavior is locked in as it is refactored. - ---- - -## 5. Target Architecture (end state) - -``` -src/ -├── main.ts # bootstrap only: pipes, filters, helmet, prefix, shutdown -├── app.module.ts # module composition only (no I/O in configure()) -├── common/ -│ ├── config/ # env schema validation + typed config namespaces -│ ├── constants/ -│ ├── decorators/ # CurrentUser, Serialize, @AuthRoles … -│ ├── enums/ -│ ├── exceptions/ # custom exception classes -│ ├── filters/ # AllExceptionsFilter, PrismaExceptionFilter -│ ├── guards/ # base token guard + composable auth guards -│ ├── interceptors/ # Serialize, Logging, Timeout -│ ├── pipes/ -│ ├── utils/ # small pure helpers (codes, digits, dates) — fully unit-tested -│ └── validators/ # class-validator custom constraints -├── infra/ # external world adapters (interfaces + impls) -│ ├── storage/ # S3StorageAdapter -│ ├── sms/ # SmsAdapter (s.api.ir) -│ ├── maps/ # MapsProvider (Neshan) -│ ├── cache/ # named Redis stores (cache, otp, session) -│ └── session/ # session middleware factory -└── modules/ - ├── auth/ - │ ├── application/ # AuthService (orchestration only) - │ ├── domain/ # OtpService, AuthStateMachine, signup policies - │ └── ...dto/controllers - ├── user/ - ├── vehicle/ - ├── address/ - ├── package/ - │ ├── application/ # PackageService (orchestration) - │ ├── domain/ # recipient/item/recipients rules, request lifecycle - │ └── matching/ # MatchingService + scoring strategies - ├── trip/ - │ ├── application/ - │ ├── domain/ # TripRequestService, TripTrackingService - │ └── ... - ├── pricing/ - │ ├── application/ - │ └── strategies/ # DistanceTierStrategy, SpecialHandlingStrategy, CityPremiumStrategy - ├── financial/ map/ notification/ dashboard/ support/ health/ token/ turf/ - └── prisma/ # PrismaService + TransactionRunner -``` - -**Layer rules:** -1. Controllers: HTTP concerns only — validate, delegate, serialize. No Prisma, no business logic. -2. Application services: orchestrate domain services + infra adapters; own transactions via `TransactionRunner`. -3. Domain services: pure business rules, unit-testable with zero mocks of Nest/Prisma internals where possible. -4. Infra adapters: the only code that talks to S3/SMS/Neshan/Redis; each behind an interface so unit tests swap in fakes. -5. No direct `cacheManager.stores[n]` positional access — named stores injected by token. -6. No `console.*` — Nest `Logger` (or a wrapper) only. - ---- - -## 6. Issue Disposition - -Every audited issue is either resolved by a phase or explicitly waived: - -| Issues | Disposition | Where | -|---|---|---| -| A-1, A-2 | Resolved — layer extraction + thin controllers | Phases 3–5 | -| A-3 | Resolved — `AllExceptionsFilter` + `PrismaErrorMapper` + `TransactionRunner` remove all boilerplate | Phase 2 (filter/mapper), Phase 4 (runner) | -| A-4 | Mitigated — session kept (no schema change allowed) but session mutation extracted into explicit domain functions with documented consistency caveats | Phases 3–4 | -| B-1..B-5, B-8..B-10 | Resolved in the phase owning the file | Phases 2–5 | -| B-6 | Resolved — `moment` removed, `getDateDifference` rewritten with `date-fns` | Phase 1 | -| B-7 | Partially resolved — `jest-mock-extended` moved to devDeps (Phase 1); cookie-parser-before-session ordering + `prisma/.env` removal remaining | Phase 1, Phase 2/6 | -| B-11 | Resolved — type-checked ESLint flat config added | Phase 1 | -| B-12 | Resolved — ts-jest `isolatedModules` (514s → ~20s); type safety via `tsc --noEmit` + ESLint | Phase 1 | -| C-1..C-7 | Resolved (C-7 partially: keep `db push`, remove committed `prisma/.env`, stop seeding on every boot) | Phase 2 | -| O-1, O-3 | Resolved | Phase 2 | -| O-2 | Waived — no schema/infra change budget; revisit post-refactor | — | -| S-1..S-8 | Resolved (S-3: token lifetime becomes configurable with sane default; rotation deferred — needs schema change) | Phases 2–4 | -| T-1..T-6 | Resolved — full test rewrite + CI gates | Phases 1, 6 | - -## 7. Risk Register - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| Hidden behavior depends on current bugs (e.g. trailing-space env key, biased OTP) | Medium | Medium | Each fix documented in phase doc + `BREAKING-CHANGES.md`; OTP bias fix is behavior-visible only in digit distribution | -| Session-based matching breaks after extraction | Medium | High | Characterization tests written **before** refactor (existing behavior locked in tests first) | -| Coverage gate blocks merge mid-refactor | Medium | Low | Thresholds enforced globally only at Phase 6; per-module expectations tracked in phase docs | -| Frontend breaks on standardized error shape | High | Medium | Error shape changed only in Phase 2 with exact before/after JSON in `BREAKING-CHANGES.md`; frontend coordination step included | -| Windows/CI dev-environment drift | Low | Low | All test commands runnable cross-platform; CI is the source of truth | - -## 8. Definition of Done (per phase) - -- [ ] All phase tasks implemented and self-reviewed -- [ ] New/changed code at 100% statements/functions/lines, ≥95% branches (module-level `coverageThreshold` where practical) -- [ ] `npm run lint` and `npm test` green locally and in CI -- [ ] Phase doc updated with "as-built" notes and any deviations -- [ ] `BREAKING-CHANGES.md` updated if any contract changed -- [ ] App boots and existing happy-path endpoints respond identically (smoke check) - -## 9. Progress Tracker - -| Phase | Status | Tests re-implemented | Coverage (module) | Notes | -|---|---|---|---|---| -| 1 — Foundation | ✅ Completed | — | baseline: 68.13/59.82/41.80/67.48 | suite 514s → ~20s; ESLint added; **all docs complete** ([phase-1-foundation.md](./phase-1-foundation.md)) | -| 2 — Security/Config | ✅ Completed | env.validation, cookie-options, bootstrap-options, prisma-error-mapper, all-exceptions.filter, transform/logging/timeout, guards (5), session provider, health | 100/100/100 (phase-2 paths ≥95 branches; lint debt tracked in Phase 4 §10.6 D-5) | `cfb158a` 40 files; BREAKING-CHANGES 2.1–2.5 applied ([phase-2-security-config.md](./phase-2-security-config.md)) | -| 3 — Auth/Session | ✅ Completed | codes, otp.service, auth-state.machine, session-utils, token.service, auth.service, transporter-signup.service, auth.controller, user.service | 100/100/100 (auth & token paths ≥95 branches) | OTP 6-digit + OtpService + OTP_CACHE; TokenService public API; AuthStateMachine + session-utils; slim AuthService + TransporterSignupService; S3StoragePort in user ([phase-3-auth-session.md](./phase-3-auth-session.md)) | -| 4 — Core Business | 🚧 In progress (Tasks 1–3) | transaction-runner, pricing engine + 6 strategies + factory + facade, matching scorer + candidate-query + corridor-analyzer | pricing+prisma 10 suites / 98 tests green (2026-09-04); module 100/100/100/95 claimed in commits, lint 17 errors tracked | Runner + pricing landed (`3110252`, `7985842`); matching/trip/package/turf pending; **Phases 1–4 rollup in §10** ([phase-4-core-business.md](./phase-4-core-business.md)) | -| 5 — Supporting Modules | ⬜ Not started | — | — | spec complete ([phase-5-supporting-modules.md](./phase-5-supporting-modules.md)) | -| 6 — Integration & CI | ⬜ Not started | — | — | spec complete ([phase-6-integration-ci.md](./phase-6-integration-ci.md)) | - - diff --git a/docs/TESTING-STRATEGY.md b/docs/TESTING-STRATEGY.md deleted file mode 100644 index 83936e2..0000000 --- a/docs/TESTING-STRATEGY.md +++ /dev/null @@ -1,91 +0,0 @@ -# Testing Strategy - -> Target policy for the whole refactor. Phase 1 sets up the infrastructure; phases 2–5 re-implement tests module by module; Phase 6 adds integration tests and switches on the hard global gate. - -## 1. Coverage Policy - -| Metric | Target | Enforcement | -|---|---|---| -| Statements | 100% | Jest `coverageThreshold.global` | -| Lines | 100% | Jest `coverageThreshold.global` | -| Functions | 100% | Jest `coverageThreshold.global` | -| Branches | ≥ 95% | Jest `coverageThreshold.global` | -| CI gate | fail build below target | `.github/workflows/test-build.yml` (Phase 6) | - -**Scope** (`collectCoverageFrom`): `src/**/*.ts`, excluding `src/main.ts` (bootstrap, exercised via integration tests in Phase 6) and `*.module.ts` (declarative DI wiring). - -**Baseline (2026-09-02, scoped, commit `ae11d31` + Phase 1 tooling):** - -| Metric | Baseline | -|---|---| -| Statements | 68.13% | -| Branches | 59.82% | -| Functions | 41.80% | -| Lines | 67.48% | - -Updated after Phase 1 (raw baseline 6.99/1.8/3.51/25.28 was inflated by an incorrect `collectCoverageFrom` -sweeping the whole repo; the scoped numbers above are the real source-tree baseline). -Reproduce with: `npm run test:cov` (see §6). - -## 2. Test Types - -| Type | Location | Runner | Infra | -|---|---|---|---| -| Unit | `src/**/*.spec.ts` (co-located) | `npm test` | none — everything mocked | -| Integration (Phase 6) | `test/integration/*.spec.ts` | `npm run test:integration` | real Postgres + Redis (Docker Compose in CI) | -| E2E (Phase 6, selective) | `test/e2e/*.spec.ts` | `npm run test:e2e` | real Postgres + Redis + mocked externals | - -The old scaffold `test/app.e2e-spec.ts` ("Hello World!") is excluded from unit runs (fixed `testRegex`) and -removed in Phase 6, replaced by real e2e smoke specs. - -## 3. Unit Test Conventions - -### 3.1 Structure -- One spec file per production file, co-located: `token.service.ts` → `token.service.spec.ts`. -- `describe('')` → nested `describe('')` → `it('should when ')`. -- Arrange–Act–Assert; no test logic in constructors/hooks beyond wiring. - -### 3.2 Mocking standards -- Class dependencies: `mockDeep()` from `jest-mock-extended`. -- Infra ports (Phase 5+): hand-written in-file fakes implementing the port interface. -- Module-level mocks (`jest.mock(...)`) only for impure utilities (`generateCode`, crypto) — prefer injecting fakes. -- `clearMocks: true` is set globally — every test must re-stub what it needs (no cross-test stub leakage). - -### 3.3 Fixtures -- Shared factories live in `test/fixtures/*.fixture.ts` (`createMockUser`, `createMockSession`, `createLocation`, …). -- Factories return **fresh deep objects per call**; module-level exported constants are read-only conveniences. -- Never mutate shared constants; create your own instance: `createMockUser({ role: RolesEnum.admin })`. - -### 3.4 Determinism -- Time-dependent tests: `jest.useFakeTimers().setSystemTime(...)` or inject a clock (Phase 3 OTP tests). -- Randomness (`generateCode`, `generateUniqueCode`): spy on the crypto-backed utility or assert on range/format, not exact values. -- No test may hit the network, require env secrets, or require Postgres/Redis. CI unit job runs with **no secrets**. - -### 3.5 What "100%" means in practice -- Every public method happy path **and** every explicit error/throw path is asserted (exception type + message). -- Every branch that represents behavior (null checks, env fallbacks, status transitions) is exercised on both sides. -- Defensive-only branches (e.g. impossible states behind `instanceof` for third-party internals) may be excluded via `/* istanbul ignore next */` **with an explanatory comment** — target is ≥95% branches precisely to allow a small, documented residue. -- Controller tests must assert delegation **and** response mapping — not just "returns the mock". - -## 4. Module Test Plans - -Detailed per-phase checklists live in the phase docs. Coverage expectations per module are tracked in the master plan's progress tracker. - -## 5. CI - -- **Phase 1:** CI runs `npm test` (unit) — now **~20 s** instead of 514 s — plus `npm run lint` and `tsc --noEmit`. -- **Phase 6:** CI gains a coverage report artifact + `coverageThreshold` gate, then the integration job (Postgres/Redis services) running `prisma db push` + `npm run test:integration`. - -## 6. Commands - -```bash -npm test # unit tests (watch mode: npm run test:watch) -npm run test:cov # unit tests + coverage report (baseline reproduction) -npm run lint # type-checked ESLint (flat config) -npx tsc --noEmit # full type check (ts-jest runs isolated, type safety lives here) -``` - -## 7. Known Trade-offs - -- `ts-jest` runs with `isolatedModules: true` for speed (514 s → 28 s). Type errors in test files are caught by `tsc --noEmit` and ESLint's type-checked rules, which the CI also runs. -- `*.module.ts` and `main.ts` are excluded from coverage targets; their logic is validated by integration tests (Phase 6). diff --git a/docs/phase-1-foundation.md b/docs/phase-1-foundation.md deleted file mode 100644 index f9df039..0000000 --- a/docs/phase-1-foundation.md +++ /dev/null @@ -1,107 +0,0 @@ -# Phase 1 — Foundation & Test Infrastructure - -> **Status:** ✅ Completed · **Doc updated:** 2026-09-02 -> -> Goal: make quality measurable and the toolchain trustworthy **before** any logic is refactored. No behavior-level API changes. - -## 1. Scope - -1. Fix the broken Jest/coverage configuration and measure a real baseline. -2. Fix broken/absent tooling (ESLint) and dependency hygiene. -3. Establish shared test fixtures for all later phases. -4. Ship the documentation set for the whole refactor. -5. Speed up the test suite (it was unusably slow). - -## 2. Tasks & As-Built Results - -### 2.1 Jest / coverage configuration ✅ -**Problem (audit T-1):** `collectCoverageFrom: ["**/*.(t|j)s"]` with `rootDir: "."` swept the entire repo (including `test/`, e2e, and everything else) and no threshold existed. - -**Changes (`package.json#jest`):** -- `collectCoverageFrom`: `src/**/*.ts` minus `main.ts` and `*.module.ts` (rationale in `TESTING-STRATEGY.md §1`). -- `coverageDirectory: ./coverage`, `coverageReporters: [text, text-summary, json-summary, lcov]` (json-summary enables per-module tracking later). -- `clearMocks: true` — no cross-test stub leakage. -- Removed `verbose: true` (noise, slower output). - -### 2.2 Test suite performance ✅ -**Problem (audit B-12):** full coverage run took **514 s** with jest worker force-exit warnings. - -**Change:** ts-jest transform now runs with `isolatedModules: true`. -**Result:** coverage run now **~20 s** (≈25× faster). Type safety is preserved by `tsc --noEmit` (CI green) + ESLint type-checked rules. - -### 2.3 Dependency hygiene ✅ -**Problem (audit B-6, B-7):** `moment` + `date-fns` both shipped; `jest-mock-extended` was a production dependency. - -**Changes:** -- `moment` **removed**; `getDateDifference` rewritten with `date-fns`' `differenceInCalendarMonths` (calendar-accurate, replaces the `asMonths() % 12` approximation). Output format unchanged. -- `toPersianDigits` gained a `(value: string | number)` signature and radix-safe parsing. -- `jest-mock-extended` moved to `devDependencies`. -- Lockfile re-synced (`npm install` clean). - -### 2.4 ESLint (new) ✅ -**Problem (audit B-11):** no ESLint config existed at all — `npm run lint` failed. - -**Change:** new `eslint.config.mjs` (flat config): -- `@eslint/js` recommended + `typescript-eslint` **recommendedTypeChecked** (projectService). -- Errors enforced from Phase 1: `no-unused-vars`, `no-floating-promises`, `no-misused-promises`, `no-empty`, `eqeqeq`, prettier. -- Warnings (fixed per-module in phases 2–5): `no-explicit-any`, `no-unsafe-*` family, `no-base-to-string`, `restrict-template-expressions`, `require-await` (removing redundant `async` changes signatures — done per module), `no-console`. -- Spec/test files: type-checked too (tsconfig now includes them); mock-heavy rules relaxed. -- **Result: 0 lint errors** (250 warnings tracked as the phases 2–5 backlog). - -**Code fixes applied to satisfy the new gates (all behavior-preserving):** -- `main.ts`: `void bootstrap();` (floating promise). -- `utilities.ts`: braced `case 'P2003'` block (lexical declaration in case). -- `user.service.ts`: unused `phoneNumber` param documented + `_`-prefixed (phone changes go through OTP flow). -- `create-vehicle.dto.ts`, `support.controller.ts`, `health.controller.ts`, `trip.controller.ts`, `trip.service.ts`, `add-funds.dto.ts`, `s3.dto.ts`, `turf.service.spec.ts`: dead imports removed. -- Guards (`deny-authorized`, `multi-token`, `ownership`, `redis-health.indicator`, `sms.service`, `trip.service`): `catch (error)` with unused binding → binding-less `catch` with explanatory comments (no behavior change). -- `auth.service.ts`: `Promise` → `Promise`. -- `auth/types/*`, `token/jwt-payload.d.ts`: dead type imports removed. -- `update-request.dto.ts`: local enum renamed to `UpdateRequestStatusEnum` with an explanatory note (validation subset is intentional — clients may only accept/reject). - -### 2.5 Shared test fixtures ✅ -New `test/fixtures/`: -- `user.fixture.ts` — `createMockUser(overrides)` (+ `mockUser`, `mockTransporterUser`). -- `session.fixture.ts` — `createMockSession(overrides)` matching the real `SessionData` augmentation (`userId`, `phoneNumber`, `userState`, `accessToken`, `packages`). -- `location.fixture.ts` — `createLocation` + `ORIGIN_LOCATION` / `DESTINATION_LOCATION` (string lat/lng, matching `Location`). -- `index.ts` barrel. - -Fixtures are used by phases 2–5; existing specs migrate to them as each module is touched. - -### 2.6 Documentation ✅ -Created `docs/` set: `CODEBASE-AUDIT.md`, `REFACTORING-PLAN.md`, `CONVENTIONS.md`, `TESTING-STRATEGY.md`, `BREAKING-CHANGES.md`, this file. - -### 2.7 Dead e2e scaffold ⏳ -`test/app.e2e-spec.ts` ("Hello World!") is excluded from unit runs by the fixed `testRegex`; it is removed in Phase 6 together with the new e2e suite (kept for now so `test/jest-e2e.json` stays meaningful). - -## 3. Baseline Metrics (scoped, after this phase's tooling fixes) - -| Metric | Baseline | Phase 6 Target | -|---|---|---| -| Statements | **68.13%** | 100% | -| Branches | **59.82%** | ≥ 95% | -| Functions | **41.80%** | 100% | -| Lines | **67.53%** | 100% | -| Suite runtime | 514 s → **~20 s** | — | -| Test suites | 28 passed / 205 tests | re-implemented by module phases | - -## 4. Validation Performed - -- `npm install` — clean. -- `npx tsc --noEmit` — exit 0 (no type regressions from the `utilities.ts` rewrite or fixtures). -- `npm run lint` — runs with the new type-checked flat config (results below). -- `npm run test:cov` — 28 suites / 205 tests, all passing, scoped coverage report produced. - -## 5. Deviations from the original plan - -- The global `coverageThreshold` block was **not** added yet — with a 41.8% function baseline it would fail CI immediately. Thresholds are enforced per-module as each phase completes and globally in Phase 6 (as recorded in `REFACTORING-PLAN.md §3`). -- Removal of the scaffold e2e spec deferred to Phase 6 (see 2.7). - -## 6. Checklist (Definition of Done) - -- [x] Jest config scoped + coverage reproducible -- [x] Baseline measured and recorded -- [x] ESLint flat config added, lint runs -- [x] `moment` removed, `jest-mock-extended` in devDependencies -- [x] Shared fixtures created -- [x] Docs set created -- [x] tsc + tests + lint validated diff --git a/docs/phase-2-security-config.md b/docs/phase-2-security-config.md deleted file mode 100644 index acd1637..0000000 --- a/docs/phase-2-security-config.md +++ /dev/null @@ -1,220 +0,0 @@ -# Phase 2 — Bootstrap, Config & Security - -> **Status:** ✅ Completed (`cfb158a`, 2026-09-03) · **Spec version:** 1.0 (2026-09-02) -> -> **Audit issues in scope:** C-1…C-7, S-1…S-5, A-3 (filter part), B-2, B-3, B-5, B-7, O-1, O-3. -> **Docs:** [CONVENTIONS.md](./CONVENTIONS.md) (layering), [BREAKING-CHANGES.md](./BREAKING-CHANGES.md) (this phase adds its first real entries). - -## 1. Objective - -Make the application boot **professionally and securely** without touching business logic: -typed configuration that fails fast, safe-by-default HTTP settings, structured error handling -replacing the copy-pasted `.catch(formatPrismaError)` pattern, consolidated auth guards, -and a working rate limiter. - -## 2. Dependencies to add - -| Package | Why | -|---|---| -| `helmet` | HTTP security headers (S-5) | -| `compression` | gzip responses | -| `@nestjs/throttler` | per-IP rate limiting (S-4) | - -`npm i helmet compression @nestjs/throttler` - ---- - -## 3. Task 1 — Typed configuration (`src/common/config/`) - -### 3.1 Env validation schema -Create `src/common/config/env.validation.ts` using `class-validator` + `plainToInstance` via -`ConfigModule.forRoot({ validate })`. Schema covers **every** env the app reads: - -```ts -// Required -DATABASE_URL, REDIS_URL, OTP_REDIS_URL, SESSION_REDIS_URL, -SESSION_SECRET, COOKIE_SECRET, -JWT_ACCESS_SECRET_KEY, JWT_TEMP_SECRET_KEY, JWT_PROGRESS_SECRET_KEY, -AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_BUCKET_NAME, AWS_ENDPOINT, -MAP_API_KEY, MAP_API_URL, SMS_API_KEY -// Optional with validated types/range -PORT, COOKIE_MAX_AGE, OTP_EXPIRATION_TIME, MAX_SEND_ATTEMPTS, MAX_CHECK_ATTEMPTS, -SEND_WINDOW, BASE_BLOCK_TIME, PRICING_*, CORRIDOR_WIDTH, AWS_REGION, -CORS_ORIGINS, THROTTLE_TTL, THROTTLE_LIMIT, API_PREFIX, LOG_LEVEL -``` - -- **Strategy:** production-like environments (CI, prod) use strict mode (missing required → boot fails). -- **Dev:** `.env.development` supplies values; missing optional keys fall back to existing code defaults. -- **Action:** replace `ConfigModule.forRoot` in `app.module.ts` with `forRoot({ isGlobal: true, validate, envFilePath })`. -- **Fix C-1 permanently:** the schema surfaces the trailing-space key immediately; Phase 3 rewires `AuthService` to the canonical key. - -### 3.2 Typed config access -- Add `src/common/config/config-names.ts` exporting **const objects** for the stringly-typed keys - (`ConfigKey.Pricing.BasePrice`, `ConfigKey.Auth.MaxSendAttempts`, …) to eliminate typos. -- Migrate only **bootstrap/shared** consumers in Phase 2 (services are migrated in their own phase). - -### 3.3 Unit tests — `env.validation.spec.ts` -- valid full env → passes -- each required key missing → fails with a message naming the key -- invalid value types (e.g. `PORT=abc`) → fails -- JWT secrets too short (<16 chars) → fails (new security rule) - ---- - -## 4. Task 2 — `main.ts` bootstrap - -Rewrite `src/main.ts` to a single, testable function. - -### 4.1 New bootstrap behavior -| Concern | Change | -|---|---| -| Security headers | `app.use(helmet())` | -| Compression | `app.use(compression())` | -| CORS | `origin: parseCorsOrigins(config)` from `CORS_ORIGINS` (comma-separated; **never** `true`) — fixes **S-1** | -| Global prefix | `app.setGlobalPrefix(config.get('API_PREFIX', 'api'))` — env-controlled, see Breaking Changes | -| Validation | global `ValidationPipe` moved here (and to a provider) with `forbidNonWhitelisted` | -| Shutdown | `app.enableShutdownHooks()` — fixes **C-5** | -| Swagger | mounted only when `NODE_ENV !== 'production'` — fixes **S-5** | -| Logging | Nest `Logger` instead of `console.log` — fixes **O-1** | -| Startup errors | `void bootstrap().catch(...)` — top-level catch — fixes **C-5** | - -### 4.2 Cookie helpers -Create `src/common/cookies/cookie-options.ts` (`getCookieOptions(config)`): one place for -`httpOnly: true`, `secure: config.get('COOKIE_SECURE', NODE_ENV === 'production')`, `sameSite` -from env, `maxAge`. **Fixes S-2** and the duplicated cookie objects in `auth.controller.ts` -(two more sites; deep links listed in Phase 3). - -### 4.3 Tests -`main.ts` is excluded from unit coverage (integration-covered in Phase 6); extract pure helpers -(`parseCorsOrigins`, `buildSwaggerConfig`, `isSwaggerEnabled`) into `src/common/http/bootstrap-options.ts` -and unit-test them at 100%. - ---- - -## 5. Task 3 — Session & middleware lifecycle (`src/infra/session/`) - -Fix **C-2** and **B-7 (parser order)**: -1. `src/infra/session/session.constants.ts` — `SESSION_STORE_PROVIDER` token. -2. `src/infra/session/session.provider.ts` — an async factory that creates the Redis client, - returns a `{ client, store, middleware }`, and registers `onApplicationShutdown` → `client.quit()`. -3. New global `SessionModule` wires **cookie-parser first**, then session middleware via - `MiddlewareConsumer` — done **synchronously** (no async `configure()`). -4. `app.module.ts` drops its `configure()` Redis setup entirely — it becomes a pure composition root - (first half of O-3). - -Tests: `session.provider.spec.ts` (fake redis client: connection, store construction, quit-on-shutdown), -`session.module.spec.ts` (middleware order assertion). - ---- - -## 6. Task 4 — Consolidated auth guards (`src/common/guards/`) - -Fix **B-2** and **B-3**: -1. `token.guard.base.ts` — `abstract BaseTokenGuard`: - - cookie name provided by subclass - - `verify()` delegates to `TokenService` - - session phone/sub comparison policy provided by subclass - - **fixed** `request.user` merge: `{ ...request.user, id: payload.sub }` (verified id is never overridden) — **B-2** - - injectable `MissingToken` / `InvalidToken` messages -2. Refactor the five guards to extend it (same public class names, same behavior/messages). - - `DenyAuthorizedGuard` keeps its inverse semantics (throws `BadRequestException(AlreadyAuthorized)`). -3. Move guard files under `src/common/guards/`; the legacy paths - (`auth/guard/token.guard.ts`, `multi-token.guard.ts`, `deny-authorized.guard.ts`, `ownership.guard.ts`) - become re-exports until Phase 3 removes them. - -Tests — **first 100% guard coverage** (audit T-3): -- each of the 5 guards: missing cookie, invalid token, expired token, phone mismatch, phone/sub match - sets `request.user.id` correctly -- `DenyAuthorizedGuard`: valid session → 400 `AlreadyAuthorized`; no/invalid token → pass-through -- `OwnershipGuard`: no config → true; no user → 403; admin → true; missing `:id` param → 400; - un-owned resource → 403 `EntityAccessDenied`; invalid entity name → error; Prisma failure → 403 fallback - ---- - -## 7. Task 5 — Global filters, interceptor, error envelope - -### 7.1 `PrismaErrorMapper` (pure — replaces the mapping half of `formatPrismaError`) -`src/common/errors/prisma-error-mapper.ts` returns `{ status, message }` per Prisma code -(P2000/P2002/P2003/P2004/P2025/P2016/validation/unknown) using the **exact** messages the current -`utilities.ts#formatPrismaError` produces (behavior-preserving), plus a `default` 500 branch. -`formatPrismaError` stays as a thin wrapper (still used by services until Phase 4 refactors them away). - -### 7.2 `AllExceptionsFilter` -`src/common/filters/all-exceptions.filter.ts`: `HttpException` → existing envelope unchanged; -Prisma errors → `PrismaErrorMapper`; unknown → 500 with `requestId` (from `RequestIdMiddleware`) -and a log line. Response keeps `{ statusCode, message, error }`. - -### 7.3 `TransformInterceptor` (optional success envelope) -`src/common/interceptors/transform.interceptor.ts` wraps success into `{ data }` **only when** -`RESPONSE_WRAPPER_ENABLED=true` (default off → byte-compatible responses). Opt-in to avoid -breaking the frontend before coordination (entry recorded in `BREAKING-CHANGES.md`). - -### 7.4 `RequestIdMiddleware` + `LoggingInterceptor` -Assign `req.id` (from `crypto.randomUUID()` or incoming `X-Request-Id`), log method/path/status/duration -via Nest `Logger`. Fixes **O-1**. - -### 7.5 `TimeoutInterceptor` -`TIMEOUT_MS` env (default 30s) → `TimeoutException` (504). - -Tests: `prisma-error-mapper.spec.ts` (every code + odd `meta` shapes), `all-exceptions.filter.spec.ts` -(every exception type, requestId present, prod/dev log behavior), `transform.interceptor.spec.ts` -(enabled/disabled, arrays, null), logging + timeout specs. - ---- - -## 8. Task 6 — Rate limiting (`@nestjs/throttler`) - -- `ThrottlerModule.forRootAsync` with `THROTTLE_TTL` / `THROTTLE_LIMIT` (env; sane defaults). -- Applied globally; OTP endpoints keep their domain-level throttling in `AuthService` - (Phase 3) — the global limiter protects the remaining endpoints (S-4). - -## 9. Task 7 — Health endpoint hardening (C-6) - -- Remove the `nestjs-docs` external `pingCheck` (unnecessary outbound traffic per poll). -- `MEMORY_HEAP_THRESHOLD_MB` env for the heap check. -- Keep DB + Redis indicators (each points at real infra). - -Test: `health.controller.spec.ts` re-implemented (stubbed `HealthCheckService`). - ---- - -## 10. Breaking changes (this phase) - -| # | Change | Frontend action | -|---|---|---| -| 2.1 | CORS now requires explicit `CORS_ORIGINS` — requests from unlisted origins are blocked | Provide frontend origin(s) to backend env | -| 2.2 | Validation 400s: unknown body fields now rejected (`forbidNonWhitelisted`) | Do not send fields not in the DTOs | -| 2.3 | `API_PREFIX` (default `api`) — all routes move under `/api/…` | Update API base URL (coordinate on the value) | -| 2.4 | Swagger disabled in production | (n/a unless docs are needed in prod) | -| 2.5 | Rate limiter returns 429 beyond `THROTTLE_LIMIT` per `THROTTLE_TTL` | Respect 429 + `Retry-After` | - -> 2.2 and 2.3 land only **after** frontend coordination (documented decision log). Everything else is safe-by-default. - ---- - -## 11. Definition of Done (checklist) - -- [x] Config schema active in `app.module`; CI/prod boot fails on missing required env vars -- [x] `main.ts` hardened (helmet, compression, CORS env, prefix, shutdown hooks, swagger gate, logger) -- [x] Session provider replaces async `configure()`; cookie-parser registered before session -- [x] Guards consolidated with zero message/behavior change; all guard specs at 100% -- [x] `AllExceptionsFilter` + `PrismaErrorMapper` at 100%; validation errors standardized -- [x] Throttler active -- [x] Health external ping removed -- [x] `tsc` exit 0, `npm test` green; Phase-2-touched modules 100/100/100/95 — **debt:** `npm run lint` reports 16 unused-import/floating-promise errors in Phase-2 specs/filter (see Phase 4 §10.6 D-5), tracked for cleanup -- [x] `BREAKING-CHANGES.md` updated (2.1–2.5); phase doc as-built filled in - ---- - -## 12. As-built notes - -**Status:** ✅ Completed 2026-09-03 (`cfb158a`, 40 files, +2320/−411). - -Landed as specified: `EnvSchema` + `ConfigKey`, `getCookieOptions`, bootstrap helpers, -`PrismaErrorMapper` + `AllExceptionsFilter`, `Transform`/`Logging`/`Timeout`, -`RequestIdMiddleware`, `infra/session/`, `BaseTokenGuard` + 5 guards, hardened `main.ts` -+ pure composition-root `app.module.ts`, health ping removal, `helmet`/`compression`/`throttler` deps. - -Deviations: none behavior-visible. Lint debt (unused imports in new specs/filter, -`health.controller.spec.ts` floating promise) recorded in Phase 4 §10.6 D-5 and fixed -with the owning phase — docs-only review 2026-09-04 made no `src/` changes. \ No newline at end of file diff --git a/docs/phase-3-auth-session.md b/docs/phase-3-auth-session.md deleted file mode 100644 index 812d941..0000000 --- a/docs/phase-3-auth-session.md +++ /dev/null @@ -1,183 +0,0 @@ -# Phase 3 — Auth, Session & User Domain - -> **Status:** ✅ Completed · **Spec version:** 1.0 (2026-09-02) -> -> **Audit issues in scope:** A-1 (auth god service), A-3 (error boilerplate removal in auth), A-4 (session mutation), B-1 (`tokenService['generateAccessToken']`), B-2 (guard merge fix — completed in Phase 2), B-3 leftovers, S-6 (OTP entropy), S-7 (`generateUniqueCode`), C-1 (trailing-space key). -> **Depends on:** Phase 2 (config, guards, filters). - -## 1. Objective - -Turn the 15 KB `auth.service.ts` (8 injected deps) into a **thin orchestration service + pure domain -services**, fix the token encapsulation violation, build an explicit auth state machine, and make -OTP handling deterministic and testable. Rewrite the auth/user/token test suites to 100%. - -## 2. Target module layout - -``` -src/modules/auth/ -├── auth.module.ts -├── auth.controller.ts # thin: body → service → cookies via cookie-options helper -├── application/ -│ ├── auth.service.ts # slim orchestrator (sendOtp, checkOtp, signup*, submitDocuments, getUserState) -│ └── transporter-signup.service.ts -├── domain/ -│ ├── otp.service.ts # OTP lifecycle + rate limiting state machine -│ ├── auth-state.machine.ts # computeTransporterState + transitions -│ └── session-utils.ts # pure session mutation helpers -├── dto/ guards/ types/ # as today (guards re-export from src/common/guards) -``` - ---- - -## 3. Task 1 — `OtpService` (`src/modules/auth/domain/otp.service.ts`) - -Move OTP logic out of `AuthService` (sendOtp/checkOtp/attempts/blocking). - -### 3.1 Responsibilities -- `generateOtp()` → uses the new `generateSecureOtp()` from `common/utils` (§4.2). -- `startSend(phoneNumber)` / `verify(phoneNumber, code)` implementing the **existing** state machine: - send attempts, check attempts, `blockedUntil`, send window, expiry — preserve exact current semantics - (characterization-tested first). -- Reads limits via an injected `OtpConfig` value object (built once from `ConfigService` in the module - factory) — this also fixes **C-1**: `MAX_SEND_ATTEMPTS` (no trailing space) is the only key read. - -### 3.2 Cache store -`AuthService`'s fragile `cacheManager.stores[1]` (C-3) becomes a named provider `OTP_CACHE` token -bound to the OTP Keyv store in `auth.module.ts`. No positional store access anywhere. - -### 3.3 Tests — `otp.service.spec.ts` -- send happy path (store + sms called once, attempts incremented) -- OTP not expired → `OtpNotExpired` (401) -- block path: `checkIfBlocked` (429), send-limit (429), check-limit (429) -- expired OTP → `OtpExpired` (401) -- wrong code increments check attempts → block after limit -- SMS failure propagates without caching a usable OTP -- fake timers for `lastSendAttempt`, `expiresIn`, `blockedUntil` - ---- - -## 4. Task 2 — Secure code generation (S-6, S-7) - -`src/common/utils/codes.ts` (move + fix): -- `generateSecureOtp()` — **6-digit, zero-padded**: `crypto.randomInt(0, 1_000_000).toString().padStart(6, '0')`. - Removes the biased `[11111, 99999)` range and yields a uniform 6-digit space. **Breaking change** — see §9. -- `generateUniqueCode()` — keep the format for compatibility but document that uniqueness is **not** - guaranteed; introduce `generateTripCode()` / `generateTrackingCode()` as **crypto-random 8-char base32** - (no timestamp prefix) where the DAL unique column enforces uniqueness, with `P2002` retry via - `TransactionRunner` (Phase 4). -- Remove `moment` remnants (already gone); keep Persian helpers in place. - -Tests: `codes.spec.ts` — length/format/padding, range, uniqueness across a large sample, -determinism via a mocked `crypto`. - ---- - -## 5. Task 3 — `TokenService` public API (B-1) - -`src/modules/token/token.service.ts`: -- Promote `generateAccessToken` / `generateTempToken` / `generateProgressToken` to **public** with typed - payloads (`AccessTokenPayload`, `TemporaryTokenPayload`, `ProgressTokenPayload` in `token.types.ts`). -- Expiries become config-driven with the **same defaults**: access `20d`, temp `20m`, progress `1d` - (`JWT_ACCESS_EXPIRES_IN`, `JWT_TEMP_EXPIRES_IN`, `JWT_PROGRESS_EXPIRES_IN`). -- `verifyToken` keeps its behavior and messages; drop the pointless local aliases (B-5). - -Tests — `token.service.spec.ts`: -- each `generate*` produces a JWT verifiable via `verifyToken` with the right type and expiry -- expired token → `"${type} token has expired"`; tampered → `"${type} invalid token"`; - unexpected error → `"${type} verification failed"`; empty/non-string → `InvalidToken` -- config-driven expiry honored (env override in the test) - ---- - -## 6. Task 4 — `AuthStateMachine` (`src/modules/auth/domain/auth-state.machine.ts`) - -Extract `computeTransporterState` into a pure class with an explicit transition table: - -``` -PersonalInfoSubmitted ──(vehicle created)──► VehicleInfoSubmitted -VehicleInfoSubmitted ──(all docs present)──► DocumentsSubmitted -DocumentsSubmitted ──(verificationStatus=verified)──► Authenticated -``` - -- `compute(transporter): { userState, transporter }` — drop-in replacement, same outputs as today. -- `AuthService.getUserState` delegates to it (session get/set stays in the service). -- Add `session-utils.ts` with pure session mutation helpers (`setUserState`, `clearAuthData`). - -Tests: `auth-state.machine.spec.ts` (matrix over vehicle/docs/verification combos), `session-utils.spec.ts`. - ---- - -## 7. Task 5 — Slim `AuthService` + `TransporterSignupService` - -- `auth.service.ts` keeps: `sendOtp`, `checkOtp` (delegating to `OtpService`), `getUserState`, - plus orchestration of the signup flows — with the multi-step bodies **moved** to - `application/transporter-signup.service.ts`. -- One `TransactionRunner` per multi-write op; error wrapping handled by the global filter (services throw - only domain/Nest exceptions — `.catch(formatPrismaError)` removed from auth paths). -- Controller: replace hardcoded cookie objects with `getCookieOptions(config)` (Phase 2, S-2); - move the `session.userState = VehicleInfoSubmitted` mutation out of the controller into a service - call (A-4 mitigation). - -Tests — re-implement `auth.service.spec.ts`, new `transporter-signup.service.spec.ts`: -- checkOtp new-user → temp token + session priming; existing-user → progress token -- signupSender / signupTransporter happy paths + constraint failures -- signupTransporter + vehicle + documents flows incl. `verificationStatusId` handling -- submitDocuments: transaction rollback on failure; access token issued + cookie behavior recorded -- getUserState matrix (cached Authenticated; transporter states; user not found; sender role) - using a mocked `AuthStateMachine` - ---- - -## 8. Task 6 — User & vehicle interplay - -- `user.service.ts` keeps `getTransporter`, `updateTransporter`, `getProfile` but drops direct - `S3Service` dependency → injects the `StoragePort` (Phase 5 defines the port; Phase 3 uses a minimal - interim `S3StoragePort` local interface so tests stay isolated). -- Re-implement `user.service.spec.ts` at 100% (profile URL generation, update stripping phoneNumber, - transporter CRUD, verification status creation, error mapping via filter). - -## 9. Breaking changes (this phase) - -| # | Change | Frontend action | -|---|---|---| -| 3.1 | OTP is now **6 digits** (was 5) and zero-padded | Update SMS/template expectations; OTP input UI must accept 6 digits | -| 3.2 | `JWT_*_EXPIRES_IN` envs — default values unchanged (`20d/20m/1d`) | none (only if envs changed) | -| 3.3 | Session `userState` write for vehicle registration now happens inside the service (same end state) | none (behavior parity) | -| 3.4 | Guard files re-exports only — no API change | none | - -## 10. Definition of Done (checklist) - -- [ ] `OtpService` extracted; `OTP_CACHE` named provider; `MAX_SEND_ATTEMPTS` typo fixed -- [ ] `generateSecureOtp` 6-digit; codes reimplemented with uniqueness enforcement path -- [ ] `TokenService` public API + config-driven expiries; no bracket-notation access anywhere -- [ ] `AuthStateMachine` + `session-utils` pure & tested -- [ ] `AuthService` slim; controller cookie flags centralized; session mutation out of controllers -- [ ] auth/user/token specs re-implemented; module coverage 100/100/100/95 (auth & token paths) -- [ ] `BREAKING-CHANGES.md` updated (3.1 …) -- [ ] `tsc` exit 0, lint 0 errors, `npm test` green - ---- - -## 11. As-built notes - -**Status:** ✅ Completed 2026-09-03. - -### Deviations from spec -- `AuthStateMachine` is an `@Injectable` class (provided in `AuthModule`) rather than a plain - instantiated class, so `AuthService` can receive a mocked instance in tests (spec asked for a - "mocked `AuthStateMachine`" in the `getUserState` matrix). -- `OtpService` reads limits/cache directly via injected `OTP_CONFIG` + `OTP_CACHE` tokens; the - `OTP_CACHE` factory still reads `cacheManager.stores[1]` — now isolated to ONE place in - `auth.module.ts` (fixes C-3; no positional access in services). -- Legacy `generateCode()` kept as deprecated compat export for the trip delivery code - (migrated in Phase 4); `generateSecureOtp()` is the 6-digit zero-padded replacement. -- `UserService` injects an interim `S3StoragePort` bound via `S3_STORAGE_PORT` token to `S3Service` - (`useExisting` in `user.module.ts`). Phase 5 replaces it with the full `StoragePort`. -- `check-otp` DTO `code` is now a **string** (6 digits); `SmsService.sendOtp` accepts a string. -- New optional env keys `JWT_ACCESS_EXPIRES_IN` / `JWT_TEMP_EXPIRES_IN` / `JWT_PROGRESS_EXPIRES_IN` - added to `env.validation.ts` + `config-names.ts`. - -### Coverage (module) -Auth + token specs re-implemented at 100% statements/lines/functions, ≥95% branches -(`codes`, `otp.service`, `auth-state.machine`, `session-utils`, `token.service`, -`auth.service`, `transporter-signup.service`, `auth.controller`, `user.service`). \ No newline at end of file diff --git a/docs/phase-4-core-business.md b/docs/phase-4-core-business.md deleted file mode 100644 index 4f5882a..0000000 --- a/docs/phase-4-core-business.md +++ /dev/null @@ -1,294 +0,0 @@ -# Phase 4 — Core Business: Package, Trip, Matching, Pricing, Turf - -> **Status:** 🚧 In progress (partial: Tasks 1–3 landed) · **Spec version:** 1.2 (2026-10-03) -> -> **Audit issues in scope:** A-1 (package 22 KB / trip 32 KB god services), A-2, A-3 (final boilerplate removal), A-4 (session mutation in transactions), B-4, B-9, B-10, S-8 (tracking PII). -> **Depends on:** Phase 2 (filter, runner-friendly error handling), Phase 3 (config/token patterns, session-utils). -> -> **Rollup note:** §10 of this doc is the consolidated as-built for Phases 1–4 -> (per docs-only review 2026-09-04). Phase 1–3 details stay in their own files; -> this file summarizes what actually landed and what remains. - -## 1. Objective - -Dismantle the two biggest service classes into **domain/application services with single -responsibilities**, make pricing a **strategy-driven engine**, extract matching scoring into a pure -scorer, and introduce a `TransactionRunner` that eliminates the repeated -`.catch(formatPrismaError); throw` boilerplate. Then lock everything with 100% tests. - ---- - -## 2. Task 1 — `TransactionRunner` (`src/modules/prisma/transaction-runner.ts`) - -> **Status:** ✅ Landed (`3110252`). - -```ts -@Injectable() -export class TransactionRunner { - constructor(config: ConfigService, private prisma: PrismaService) {} - run(fn: (tx: PrismaTransaction) => Promise, options?: TransactionRunnerOptions): Promise; - runIsolated(fn: (tx: PrismaTransaction) => Promise, options?: TransactionRunnerOptions): Promise; -} -``` - -- Central error conversion is **removed** from this layer: the global `AllExceptionsFilter` (Phase 2) - already maps Prisma errors, so `run` just re-throws. Services stop importing `formatPrismaError` (A-3 done). -- Default `timeout` configurable (`PRISMA_TX_TIMEOUT_MS`, default 5000) with an explicit `TransactionTimedOutError`. - -Tests — `transaction-runner.spec.ts`: success returns value; rollback on inner throw; timeout path; -re-throw preserves original error instance. - ---- - -## 3. Task 2 — Pricing engine & strategies (`src/modules/pricing/strategies/`) - -> **Status:** ✅ Landed (`7985842`). Slim facade preserves the public API consumed by -> `package.service` (`calculateSuggestedPrice` / `calculateTransporterEarnings` / -> `calculateDeviationCost` / `calculateDistanceCost`). - -Replace the monolithic `PricingService` with a composition of testable strategies (same public API + defaults): - -| Strategy | File | Responsibility | Bug fixed | -|---|---|---|---| -| `DistanceTierStrategy` | `distance-tier.strategy.ts` | tiered per-km cost; **guard clause** for `remainingDistance <= 0`; explicit `tierCredit` formula via config bounds (no magic `+1`) | **B-10** | -| `WeightStrategy` | `weight.strategy.ts` | weight surcharge (current: free <500 g) | — | -| `SpecialHandlingStrategy` | `special-handling.strategy.ts` | fragile/perishable/both multipliers | — | -| `CityPremiumStrategy` | `city-premium.strategy.ts` | major-city factors, `PRICING_MAJOR_CITIES` parsing | — | -| `DeviationCostStrategy` | `deviation-cost.strategy.ts` | distance + time deviation | — | -| `EarningsStrategy` | `earnings.strategy.ts` | transporter share (`driverShare`, default 0.7) | — (added during implementation; spec §3 listed 5, code ships 6) | - -- `PricingEngine` orchestrates: `calculateSuggestedPrice(input): { suggestedPrice, breakdown }`, - `calculateTransporterEarnings`, `calculateDeviationCost`. - -- **Decision on C-4:** `PRICING_PLATFORM_COMMISSION` remains unused by design (commission = `1 − driverShare`); - the README env list is corrected in Phase 6, and `env.validation.ts` documents the canonical set. - Alternatively, if the product wants an explicit commission env, that becomes a product decision — tracked here. - -Tests — table-driven per strategy (boundary km tiers, zero weight, multipliers each branch, city premium -each quadrant incl. case-insensitive major-city match, deviation) + engine integration (breakdown math -matches current output for a fixed input — record the golden value). - ---- - -## 4. Task 3 — Matching & Turf (`src/modules/package/matching/`) - -> **Status:** ✅ Landed (branch `refactor/phase-4-matching`). - -Extract pure, highly-tested pieces from `matching.service.ts`: - -- `MatchingScorer` — `calculateMatchingScore(originDistance, destinationDistance, isOnCorridor)` - (current rules: corridor penalty, ±500 close-point bonuses, clamp ≥0). -- `CorridorAnalyzer` — `analyzeTrip(tripRoute, packageOrigin, packageDestination, corridorWidthKm)`: - distance-to-route via TurfService, corridor + direction checks. Returns `MatchResult | null`. -- `TripCandidateQuery` — builds the pre-filter Prisma where-clause (active+scheduled, `updatedAt` >= lastCheck, - weight capacity OR-null) — pure, takes a small typed input. -- `MatchingService` keeps orchestration (session bookkeeping, parallel analysis, merge+sort+limit) - but delegates scoring/analysis; **no console.error** — Nest `Logger` (B-4). -- Open TODOs documented: departure-time filtering, transporter-rating scoring (MVP gap — no behavior change). - -Tests: `matching-scorer.spec.ts` (boundaries: on/off corridor, <1000 m bonuses, zero clamp), -`corridor-analyzer.spec.ts` (in/out corridor, reversed direction, missing waypoints), -`trip-candidate-query.spec.ts` (no lastCheck, with lastCheck, weight present/null), `matching.service.spec.ts` -(session creation, merge/sort/limit, allSettled behavior preserved — including the swallow-null path). - ---- - -## 5. Task 4 — Split `TripService` (32 KB → 3 services) - -> **Status:** ⬜ Not started (no `trip/application/` or `trip/domain/` files yet). - -The 6-dep monolithic `TripService` becomes: - -| Service | File | Responsibilities moved from today | -|---|---|---| -| `TripService` (slim) | `src/modules/trip/application/trip.service.ts` | `create`, `getById`, `getMultipleById`, `getAll`, trip CRUD state helpers | -| `TripRequestService` | `src/modules/trip/domain/trip-request.service.ts` | `updateRequest` (accept/reject), `createRequest`, `cancelRequest`, request list | -| `TripTrackingService` | `src/modules/trip/domain/trip-tracking.service.ts` | tracking code generation, `addTrackingUpdate`, `getTripRoute`, rate-trip | - -Key refactors: -- `create`: keep `calculateDistance` (map) + vehicle-ownership `Forbidden` + notification — but use - `TransactionRunner`, no manual `.catch`. -- `updateRequest`: current `if (rejected)` branch keeps its behavior; the DTO-local enum stays (Phase 1). -- `cancelRequest`: **move the session mutation out of the DB transaction** — call a `session-utils` - helper *after* the tx commits (A-4 mitigation), and add a comment documenting the eventual-consistency - caveat (Redis session is not transactional with Postgres). -- `rateTrip` + financial escrow interplay stays in `TripRequestService` with the intentional - **non-fatal escrow failure** behavior preserved (comment already added in Phase 1). -- `TripController` splits route handling into the three services (public method names preserved for - controller delegation). - -Tests — re-implement `trip.service.spec.ts`; add `trip-request.service.spec.ts` (accept→escrow+notification+ -status; reject→transaction; cancel→session isolation + notification; ownership errors; wrong status enum rejected) -and `trip-tracking.service.spec.ts` (code format, update creation, route assembly, rate validation). - ---- - -## 6. Task 5 — Split `PackageService` (22 KB → 3 services) - -> **Status:** ⬜ Not started (no `package/application/` or `package/domain/` files yet; -> S-8 PII mask and B-9 `JsonArray` removal still pending). - -| Service | File | Responsibilities | -|---|---|---| -| `PackageService` (slim) | `src/modules/package/application/package.service.ts` | package CRUD, price suggestions, status flow, `findMatchedTrips` orchestration | -| `RecipientService` | `src/modules/package/domain/recipient.service.ts` | recipient CRUD + address creation (city→province resolution) | -| `PackageRequestService` | `src/modules/package/domain/package-request.service.ts` | trip requests from package side, cancel request, session update | -| `TrackingService` | `src/modules/package/domain/tracking.service.ts` | `getTrackingByCode` | - -Key refactors: -- `createRecipient`: city lookup + address create in one `TransactionRunner` op; error mapping via filter. -- `findMatchedTrips`/matching orchestration delegates to `MatchingService`; session helpers from `session-utils`. -- `getTrackingByCode` — **S-8 fix**: remove `sender.phoneNumber` from the public response - (masked as `+98•••••123` or dropped) — breaking change listed in §8. -- Remove the internal `generated/prisma/runtime/library` `JsonArray` import (B-9): type the breakdown - payload with an exported `PackageBreakdown` interface. - -Tests — re-implement `package.service.spec.ts`; add recipient/tracking/request specs covering every -status transition + failure path; tracking PII test asserts the phone is masked. - ---- - -## 7. Task 6 — TurfService & leftover cleanup - -> **Status:** ⬜ Not started. - -- `turf.service.ts` stays (pure geometry wrapper) but gains `createRoute`, `createPoint`, - `getDistanceToRoute`, `checkDirectionCompatibility` — all already there; add **100% specs** with - real Turf calls (no mocks needed) incl. `null`/degenerate route inputs. -- `notification-messages.ts`: keep; add spec for placeholder substitution incl. missing-context leaves - `{key}` untouched. -- Remove `MapService→PrismaService` dependency from the package/trip paths if still reachable - (the city lookups move to a `CityRepository` in Phase 5 — referenced here so it is not skipped). - ---- - -## 8. Breaking changes (this phase) - -| # | Change | Frontend action | -|---|---|---| -| 4.0 | **Partial landing (Task 1 + Task 2, 2026-09-04): no API-visible change.** Pricing output is byte-identical by golden parity; `TransactionRunner` is internal only | none | -| 4.1 | `/tracking/:code` response no longer includes `sender.phoneNumber` (masked/dropped, S-8) — **pending** (Task 5 not started) | Stop relying on sender PII in tracking screens | -| 4.2 | Pricing output must remain **byte-identical** (golden test enforced) | none — if drift detected, treat as bug and record here | - -## 9. Definition of Done (checklist) - -- [x] `TransactionRunner` added with configurable timeout + `run`/`runIsolated` (`3110252`); adoption across package/trip paths still pending — A-3 not yet resolved phase-wide -- [x] Pricing engine + 6 strategies with table-driven tests + golden parity (`7985842`); golden output test for backward equality present as `pricing.engine.spec.ts` -- [ ] Matching/scoring extracted and 100% tested; console logging removed (Logger) — **done for matching (Task 3); Turf full specs remain Task 6** -- [ ] Trip split complete: request/tracking domain services 100% -- [ ] Package split complete: recipient/request/tracking services 100% -- [ ] Tracking PII masked + tested; `JsonArray` internal import removed -- [ ] Turf & notification-messages specs at 100% -- [ ] session mutation moved out of tx for `cancelRequest` (A-4) -- [ ] `tsc` exit 0, lint 0 errors, `npm test` green; touched modules 100/100/100/95 — **partial:** `tsc` clean, pricing+prisma 10 suites / 98 tests green, lint has 17 errors (see §10.6) -- [ ] `BREAKING-CHANGES.md` updated (4.0 no-op entry added; 4.1 still pending) - ---- - -## 10. As-built notes - -> Docs-only review 2026-09-04 (branch `refactor/core-business` @ `7985842`). -> No `src/` changes in this review — deviations are recorded as debt for follow-up. - -### 10.1 Status snapshot - -- Phase 4 Tasks 1–2 landed; Tasks 3–6 untouched. Proof: `git diff master...HEAD --name-only` - lists only `src/common/config/*`, `src/modules/pricing/**`, `src/modules/prisma/transaction-runner*`. -- `src/modules/package/matching/`, `trip/application/`, `trip/domain/`, - `package/application/`, `package/domain/` do not exist yet. - -### 10.2 Rollup — what Phases 1–4 actually changed - -| Phase | Commit | Landed change | Evidence | -|---|---|---|---| -| 1 — Foundation | `06e0ae9` | Jest scoped to `src/**/*.ts`, `isolatedModules` (514 s → ~20 s), ESLint flat type-checked config, `moment` removed (`date-fns` rewrite), `jest-mock-extended` → devDeps, `test/fixtures/` (user/session/location) | `docs/phase-1-foundation.md` as-built; baseline 68.13/59.82/41.80/67.53 | -| 2 — Bootstrap/Config/Security | `cfb158a` | `EnvSchema` + `ConfigKey`, `getCookieOptions`, `parseCorsOrigins`/swagger helpers, `PrismaErrorMapper` + `AllExceptionsFilter`, `Transform`/`Logging`/`Timeout` + `RequestIdMiddleware`, `infra/session/` (cookie-parser first, sync wiring, quit-on-shutdown), `BaseTokenGuard` + 5 guards (B-2 merge fix), `main.ts` hardened (helmet, compression, CORS env, prefix, shutdown, swagger gate, Logger), health external ping removed | `docs/phase-2-security-config.md` §11–12; `BREAKING-CHANGES.md` 2.1–2.5 applied | -| 3 — Auth/Session/User | `ea84577` | `codes.ts` (`generateSecureOtp` 6-digit zero-padded; legacy `generateCode` kept deprecated), `TokenService` public typed API + `JWT_*_EXPIRES_IN` (20d/20m/1d), `OtpService` + `OTP_CACHE`/`OTP_CONFIG` (fixes C-1 trailing-space key, C-3 positional store), `AuthStateMachine` (Injectable for mocking), `TransporterSignupService`, `session-utils.ts`, `check-otp.code: string`, `SmsService.sendOtp(string)`, interim `S3_STORAGE_PORT` in user | `docs/phase-3-auth-session.md` §11; `BREAKING-CHANGES.md` 3.1–3.4 applied | -| 4 partial — Runner | `3110252` | `TransactionRunner` (`run` + `runIsolated` serializable), `ConfigKey.Database.TransactionTimeoutMs` + `PRISMA_TX_TIMEOUT_MS` (default 5000), `P2028 → TransactionTimedOutError`, re-throw otherwise (filter owns mapping), wired in `prisma.module.ts` + `transaction-runner.spec.ts` | §10.3 | -| 4 partial — Pricing | `7985842` | `PricingEngine` (pure pipeline, legacy arithmetic order preserved) + 6 strategies + `buildPricingStrategies` factory + slim `PricingService` facade (same 4 public methods); B-10 tier spans derived (`upToKm − previousUpToKm`); table-driven specs + golden-equality engine spec + facade spec | §10.4 | - -### 10.3 Task 1 as-built (`TransactionRunner`) - -- Files: `src/modules/prisma/transaction-runner.ts`, `transaction-runner.spec.ts`, - `prisma.module.ts`, `src/common/config/config-names.ts`, `env.validation.ts` - (`PRISMA_TX_TIMEOUT_MS` optional int). -- Extension vs spec §2: constructor takes `ConfigService` (not just `PrismaService`); - `run(fn, options?)` / `runIsolated(fn, options?)` with `{ timeoutMs, isolationLevel }`; - `runIsolated` forces `Serializable` unless overridden. Documented here; spec signature above updated. -- Behavior: timeout resolved per-call (`timeoutMs ?? defaultTimeoutMs`) and passed to - `prisma.$transaction(fn, { timeout, isolationLevel })`; only `code === 'P2028'` maps to - `TransactionTimedOutError`, everything else re-thrown untouched for `AllExceptionsFilter`. -- Tests (`transaction-runner.spec.ts`): success value, rollback on inner throw, - timeout mapping, original-error preservation. Part of the 10-suite / 98-test green run (§10.7). - -### 10.4 Task 2 as-built (Pricing) - -- Files: `pricing.engine.ts`, `pricing.service.ts` (facade), `strategies/`: - `distance-tier`, `weight`, `special-handling`, `city-premium`, `deviation-cost`, - `earnings`, `pricing-factory` (+ 8 co-located specs: engine, facade, 6 strategy/factory specs). -- Parity: `PricingEngine.calculateSuggestedPrice` keeps legacy order - (base + distance + weight → `× specialMultiplier` → `× cityPremium` → round to nearest 1000); - `DistanceTier.getSpanKm()` = `upToKm − previousUpToKm` (Infinity for open tier) with - `remainingDistance <= 0` guard, reproducing the legacy `maxKm − minKm + 1` output without the `+1`. - `CityPremiumStrategy` is case-insensitive over `parseMajorCities(...)`. -- Commit message cites a 500-check parity harness + golden capture proving byte-identical - output across 5 config variants. No `pricing-golden.json` is committed in the diff — - treat the harness as ephemeral and `pricing.engine.spec.ts` as the durable golden test. -- C-4 decision stands: `PRICING_PLATFORM_COMMISSION` stays unused (`commission = 1 − driverShare`); - README correction deferred to Phase 6. - -### 10.5 Remaining (Tasks 3–6 — not started) - -- Matching scorer / corridor analyzer / candidate query + `Logger` swap (B-4). -- Trip split, package split (incl. S-8 PII mask, B-9 `JsonArray` removal, A-4 session-out-of-tx). -- Turf 100% specs + `notification-messages` spec + `MapService → CityRepository` (Phase 5 ref). - -### 10.6 Deviations / documented debt (docs-only — no code fix in this review) - -| # | Drift | Location | Follow-up | -|---|---|---|---| -| D-1 | Spec listed 5 strategies; code ships 6 (`EarningsStrategy`) | §3 table | Table updated; no action | -| D-2 | `pricing-factory.ts` uses string literals `'PRICING_*'` instead of `ConfigKey.Pricing.*` | `strategies/pricing-factory.ts:16-45` | Migrate to `ConfigKey` when pricing is next touched (CONVENTIONS §4) | -| D-3 | `EnvSchema` types all `PRICING_*` multipliers/share as `@IsInt()` — rejects valid floats (`1.25`, `0.7`) | `env.validation.ts:85-108` | Change to `@IsNumber()` + range check in a later phase | -| D-4 | Factory defaults diverge from `README.md` env table (weight 10000 vs 8000; dest 1.3 vs 1.2; small 1.2 vs 1.15; deviation 15000/5000 vs 2000/1500; tiers 1000/950/850/750/600 vs 1200/1000/850/750/650; major-cities 3 vs 6) | `pricing-factory.ts:15-78` vs `README.md` | Reconcile in Phase 6 README pass (C-4); treat factory as source of truth until then | -| D-5 | Lint: 1 new Phase-4 error (`pricing.engine.spec.ts:12 TABRIZ` unused) + 16 pre-existing errors (unused imports in Phase-2 specs/filter, `health.controller.spec.ts` floating promise) + 236 warnings | `npm run lint` 2026-09-04 | Fix `TABRIZ` + unused imports with the phase that owns each file | -| D-6 | Golden harness not committed | commit `7985842` message | Keep `pricing.engine.spec.ts` as durable proof; optionally commit harness artifact later | - -### 10.7 Verification performed (2026-09-04, branch `refactor/core-business`) - -- `npx tsc --noEmit` — exit 0. -- `npm test -- src/modules/pricing src/modules/prisma` — 10 suites / 98 tests passed. -- `npm run lint` (= `eslint --fix` per `package.json`) — converges to **17 errors / 236 warnings** - (1 new Phase-4 error `pricing.engine.spec.ts:12 TABRIZ` unused + 16 pre-existing in Phase-2 - specs/filter + `health.controller.spec.ts` floating promise; see D-5). The `--fix` formatting - side effects on `src/` were reverted (`git restore src/`) to keep this review docs-only. - Note: raw `eslint --no-fix` on a Windows checkout reports ~19k `prettier/prettier Delete ␍` - line-ending hits — pre-existing CRLF env noise, not real debt. -- `git diff master...HEAD --stat` — 23 files, +1113/−723, confined to config/pricing/prisma. -- Full-suite `npm test` / `test:cov` not re-run here; commit `7985842` records 53/53 suites, 401/401 tests. -- Docs plumbing: `/docs` removed from `.gitignore` (it was added together with a mass - `docs/` deletion in `2c50a47`, which is why `docs/` was untracked) so this improvement set is - committable; `/generated` stays ignored. Root `phase-1-foundation.md` deleted (docs/ canonical). - -### 10.8 Task 3 as-built (2026-10-03, branch `refactor/phase-4-matching`) - -- Files: `src/modules/package/matching/matching-scorer.ts` (+spec), - `trip-candidate-query.ts` (+spec), `corridor-analyzer.ts` (+spec); - slimmed `src/modules/package/matching.service.ts` (delegates, `Logger`, - `ConfigKey.Pricing.CorridorWidth`); `turf.service.ts` `console.error` → `Logger`; - `test/fixtures/match.fixture.ts` (+ barrel export). -- Parity: scorer/query/analyzer moved verbatim; `MatchingService` keeps public - signature + session merge/sort/limit + swallow-`null` `allSettled` behavior; - private `getPreFilteredTrips`/`analyzeTrip`/`calculateMatchingScore` kept as thin - delegates (backward-compatible for spies). No API-visible change (BREAKING 4.0 stands). -- Decisions: `ConfigKey` used now (pays down D-2 pattern for matching; pricing - factory migration still pending); `CorridorAnalyzer` is a plain class constructed - inside the service (pricing-facade pattern — no module wiring change). -- Gotcha: `expect.anything()` does **not** match `jest-mock-extended` `mockDeep` - proxies — assert by reference (`prismaService`) instead. -- Verification: `tsc` exit 0; `npm test -- src/modules/package src/modules/turf - src/modules/prisma src/modules/pricing` — 17 suites / 183 tests green; - scoped `eslint --fix` on touched files — **0 errors** (2 `require-await` - warnings: `analyzeTrip` stays `async` for the `.catch` chain — same as before). - Matching units at 100/100/100/95+; `turf.service.ts` full 100% remains Task 6 - (only the touched `getDistanceToRoute` fallback path is now covered). \ No newline at end of file diff --git a/docs/phase-5-supporting-modules.md b/docs/phase-5-supporting-modules.md deleted file mode 100644 index 8330291..0000000 --- a/docs/phase-5-supporting-modules.md +++ /dev/null @@ -1,128 +0,0 @@ -# Phase 5 — Supporting Modules & Infrastructure Adapters - -> **Status:** ⬜ Not started · **Spec version:** 1.0 (2026-09-02) -> -> **Audit issues in scope:** A-2 (layering / MapService→Prisma), B-4 (console logging), B-8, O-1, remaining `no-unsafe-*`/`require-await` lint warnings on these modules. -> **Depends on:** Phases 2–4 (filters, runner, conventions, ports patterns). - -## 1. Objective - -Finish the layering: extract the external-world access behind **ports + adapters** (`src/infra/`), -remove the remaining module gods (`user`, `financial`, `map`, `dashboard`, `notification`, support, -vehicle, address), standardize DTO response mapping, and reach **100/100/100/95** on every module. - ---- - -## 2. Task 1 — Infra adapters (`src/infra/`) - -### 2.1 `src/infra/ports/` (interfaces, no Nest) -| Port | Methods | Used by | -|---|---|---| -| `StoragePort` | `generatePutPresignedUrl(key, expiresIn)`, `generateGetPresignedUrl(key, expiresIn)`, `deleteFile(key)`, `fileExists(key)` | user, package, trip, s3, dashboard | -| `SmsPort` | `sendSms(mobiles[], message)`, `sendOtp(mobile, code)` | auth | -| `MapsPort` | `calculateDistance(input)`, `reverseGeocode(location)`, `routeDirections(input)` | map, trip, package, turf | -| `CodeGeneratorPort` (optional) | `otp()`, `tripCode()`, `trackingCode()` | auth, trip, package | - -### 2.2 Implementations (`src/infra/**/`) -- `storage/s3-storage.adapter.ts` — **move** S3 client logic from `s3.service.ts` (client creation from - config, presign, delete, exists). `S3Service` becomes a thin `@Injectable` delegating to the adapter - (keeps existing importers working) **or** is deleted with imports migrated to the port token. - Decision marker: prefer deleting `s3.service.ts` if the diff stays small (controllers import the token). -- `sms/sms.adapter.ts` — move current `SmsService` HTTP logic; keep exact URLs/headers/payloads; - add a **retry-once** on 5xx (documented deviation) and keep error messages identical. -- `maps/neshan-maps.adapter.ts` — move direction/reverse-geocode calls from `map.service.ts`. - -### 2.3 Provider binding -`PORTS.STORAGE` / `PORTS.SMS` / `PORTS.MAPS` tokens exported from `src/infra/ports/ports.tokens.ts`; -bound in the owning modules (`S3Module`/`SmsModule`/`MapModule`) so unit tests can `overrideProvider`. - -Tests — adapter specs with mocked `HttpService`/`S3Client`: success payloads, error propagation, -presign argument shape, header/cookie placement. - ---- - -## 3. Task 2 — `MapService` refactor (A-2 fix) - -- `src/modules/map/map.service.ts` becomes an application service using `MapsPort` **only**. -- City lookups (`getIntermediateCitiesWithIds`) move to a new `CityRepository` - (`src/modules/prisma/repositories/city.repository.ts`) injected into map/package/trip — this removes - the Prisma dependency from the infra client for good. -- `getIntermediateCitiesWithCoords/WithIds` keep their endpoints; `extractSignificantPoints` and - `haversineDistance` become exported pure helpers (`map/route-filters.ts`) with **100% tests** - (roundabout steps, `وارد` instruction, min-distance dedup, empty legs). - -Tests: `route-filters.spec.ts`, `city.repository.spec.ts`, `map.service.spec.ts` (HttpService mocked). - ---- - -## 4. Task 3 — Remaining module services - -### 4.1 `UserService` -- Switch S3 calls → `StoragePort`; `getProfile` mapping stays (response DTOs via `Serialize`). -- Remove `formatPrismaError` wrappers (global filter). Update `user.service.spec.ts` for the port mock. - -### 4.2 `FinancialService` -- Split concerns: `WalletService` (getWallet, addFunds), `EscrowService` (createEscrow/release/refund). -- Decimal-safety: BigInt amounts stay as `bigint` internally; **all** public conversions use a single - `formatMoney(bigint)` helper (`common/utils/money.ts`, unit tested incl. negative/zero). -- Payment gateway integration stays a documented TODO (no schema/env budget). - -### 4.3 `NotificationService` -- Replace `console.*` with Nest `Logger`; add `unreadCount` query; keep `create(content, tx)` signature. -- Specs: create inside/outside tx, unread listing, placeholder substitution via `notification-messages`. - -### 4.4 `DashboardService` -- Already reads through `getDateDifference` (date-fns, Phase 1). Remove `formatPrismaError` wrappers; - inject `StoragePort`; single source for statistics builders; 100% specs for both role branches. - -### 4.5 `VehicleService` / `AddressService` / `SupportService` -- Thin services: keep logic, use `TransactionRunner`, remove `formatPrismaError`; response mapping via - existing `*ResponseDto`s; `SupportService.updateVerification` guards against missing - `verificationStatusId` with a `NotFoundException` (documented behavior addition). - -### 4.6 `S3Controller` -- Add the missing upload **size limit** (audit TODO): `MAX_UPLOAD_SIZE_MB` env + presign-time - content-length handling or a pre-upload validation endpoint (documented decision marker). - ---- - -## 5. Task 4 — Full-module test sweep - -Each touched module gets its `*.service.spec.ts` and `*.controller.spec.ts` re-implemented: -assert **mapping and delegation**, not just "returns the mock". Cover: -- controller 400/404/200 wiring per route, Swagger decorators not asserted -- services: happy path, each throw path, transaction rollback, BigInt formatting -- `@Serialize(ResponseDto)` interceptor path on the response objects (use real `plainToInstance`) - -**Coverage gate for the phase:** every module directory listed in this phase at 100/100/100/95. - ---- - -## 6. Breaking changes (this phase) - -| # | Change | Frontend action | -|---|---|---| -| 5.1 | `SupportService.updateVerification` throws **404** when transporter has no `verificationStatusId` (was a possible 500) | none normally; handle 404 in admin UI | -| 5.2 | S3 upload size limit enforced (env `MAX_UPLOAD_SIZE_MB`) | Upload UI should pre-check file size | -| 5.3 | `S3Service` → port token refactor is internal (same endpoints) | none | - ---- - -## 7. Definition of Done (checklist) - -- [ ] `src/infra/` exists with `StoragePort`/`SmsPort`/`MapsPort` + adapters; no direct AWS/SMS/Neshan - calls outside adapters -- [ ] `MapService` no longer depends on `PrismaService`; `CityRepository` extracted -- [ ] `User`, `Financial` (wallet/escrow), `Notification`, `Dashboard`, `Vehicle`, `Address`, `Support` - cleaned (runner, filter, ports, Logger) -- [ ] S3 size limit shipped and tested -- [ ] Every module spec (service + controller) re-implemented; phase modules at 100/100/100/95 -- [ ] `tsc` exit 0, lint **0 errors** (warnings backlog significantly reduced — `no-unsafe-*` gone on - touched files), `npm test` green -- [ ] `BREAKING-CHANGES.md` updated (5.1–5.3) - ---- - -## 8. As-built notes - -*(Filled when the phase completes — record deviations, actual coverage numbers, timings.)* \ No newline at end of file diff --git a/docs/phase-6-integration-ci.md b/docs/phase-6-integration-ci.md deleted file mode 100644 index 8c67a60..0000000 --- a/docs/phase-6-integration-ci.md +++ /dev/null @@ -1,117 +0,0 @@ -# Phase 6 — Integration Tests, CI & Final Docs - -> **Status:** ⬜ Not started · **Spec version:** 1.0 (2026-09-02) -> -> **Audit issues in scope:** O-2 (partial), T-1 (coverage gate), T-5 (dead e2e), T-6 (CI secrets), C-7 (docker/entrypoint), O-3. -> **Depends on:** Phases 1–5 (all code refactored, all unit tests re-implemented). - -## 1. Objective - -Prove the whole thing end-to-end against **real Postgres + Redis** in CI, turn the 100/95 coverage -targets into an enforced CI gate, retire the scaffold e2e test, update ops files (`entrypoint.sh`, -`Dockerfile`, compose), and finalize documentation including the frontend digest. - ---- - -## 2. Task 1 — Integration test suite (`test/integration/`) - -### 2.1 Infrastructure -- `docker-compose.test.yml` — postgres:17 + redis:8 (same images/versions as `docker-compose.dev.yml`), - no app service; fixed host ports for local runs. -- `test/integration/setup.ts` (Jest `globalSetup`) — sets `DATABASE_URL`/`REDIS_URL`/`SESSION_REDIS_URL` - to the test services, runs `prisma db push` (schema is unchanged, so no migrations needed), then - truncates tables between suites via a helper. - -### 2.2 Test categories -| Suite | What it proves | -|---|---| -| `auth.integration.spec.ts` | real OTP store (Redis) flow: send → verify → session/token issuance; rate-limit block | -| `users.integration.spec.ts` | user + transporter CRUD against real DB, unique-constraint 409s | -| `packages.integration.spec.ts` | recipient/address create with city→province; package flow; tracking PII check | -| `trips.integration.spec.ts` | trip create w/ ownership; request accept → escrow; tracking update assembly | -| `pricing.integration.spec.ts` | golden price equals unit-test golden (config wired correctly) | -| `health.integration.spec.ts` | `/health` responds OK when DB+Redis up | - -External calls (SMS, Neshan, S3) are **mocked at the port boundary** (from Phase 5) via provider overrides -in the integration `TestingModule` — no network, no secrets (T-6). - -### 2.3 Runner wiring -- `test/jest-integration.json` (testEnvironment node, `setupFilesAfterEnv`, longer timeout 30s). -- `npm run test:integration` — runs db push (against `TEST_DATABASE_URL`) + integration suite. - ---- - -## 3. Task 2 — CI pipeline rewrite (`.github/workflows/test-build.yml`) - -``` -jobs: - test: # lint + types + unit (NO secrets) - steps: npm ci → prisma generate → lint → tsc --noEmit → npm test → coverage report (upload artifact) - integration: # needs test - services: postgres + redis (as today) - steps: npm ci → prisma generate → prisma db push → npm run test:integration - build: # needs integration, only on master push - steps: docker build/push (unchanged target runtime) -``` - -- Unit job env: **remove all secrets** (only non-secret `NODE_ENV=test`). -- Integration job: only `TEST_DATABASE_URL` style vars + mocked port overrides (no AWS/SMS/MAP secrets). -- Coverage gate lives in Jest (`coverageThreshold`), so CI fails naturally — plus upload - `coverage/lcov-report` as an artifact. - -### 3.1 Jest `coverageThreshold` (global) -```json -"coverageThreshold": { - "global": { "statements": 100, "functions": 100, "lines": 100, "branches": 95 } -} -``` -Rationale: policy from TESTING-STRATEGY; `main.ts` + `*.module.ts` stay excluded. If a carve-out is -needed, it is added to `TESTING-STRATEGY.md` with justification **before** the gate is switched on. - ---- - -## 4. Task 3 — Ops files -- `entrypoint.sh`: split into `entrypoint.sh` (schema push only) and **seed as an explicit, idempotent - npm script** (`seed` already exists) — **stop seeding on every container boot** (O-3); add - `SEED_ON_BOOT` env (default false) honored by the script. -- `Dockerfile`: verify `dev`/`runtime` targets still match new `npm run` scripts; no schema change. -- `prisma/.env`: remove from git; document in `.env.example` (C-7). -- Add `npm run prisma:generate:ci`? Not needed — CI already calls `prisma generate`. - ---- - -## 5. Task 4 — e2e smoke suite (`test/e2e/`) -Retire `test/app.e2e-spec.ts` ("Hello World!") and replace with: -- `health.e2e-spec.ts` — boots the full `AppModule` with ports mocked, asserts `/health` 200. -- `validation.e2e-spec.ts` — POST to a known route with an unknown field → 400 standardized envelope. -- `not-found.e2e-spec.ts` — unknown route → 404 envelope incl. `requestId` (Phase 2 filter). -These run with the **same infra as integration** (postgres + redis) via `npm run test:e2e`. - ---- - -## 6. Task 5 — Final documentation & frontend digest -- Update `README.md`: testing section (three tiers), coverage badge, new env vars (`CORS_ORIGINS`, - `API_PREFIX`, `THROTTLE_*`, `JWT_*_EXPIRES_IN`, `MAX_UPLOAD_SIZE_MB`, `TIMEOUT_MS`, `LOG_LEVEL`), - corrected pricing env table. -- `BREAKING-CHANGES.md`: consolidate all entries (2.x–5.x) into a single **frontend migration guide** - with FAQ + timeline. -- `REFACTORING-PLAN.md`: final progress tracker; close all issues in the disposition table. -- `CODEBASE-AUDIT.md`: refresh severity table (what's fixed), keep as historical record. - ---- - -## 7. Definition of Done (final, whole-project) - -- [ ] Integration suite green in CI against Dockerized Postgres/Redis (no secrets) -- [ ] e2e smoke suite green; scaffold "Hello World!" removed -- [ ] Coverage gate enforced — **global 100/100/100/95**; CI fails below with artifact -- [ ] CI: test(lint+unit) → integration → build(push) all green -- [ ] Ops files updated (no per-boot seeding; `prisma/.env` untracked) -- [ ] README + docs finalized; `BREAKING-CHANGES.md` digest delivered to frontend -- [ ] Final full run: `npm run lint` (0 errors), `npx tsc --noEmit`, `npm run test:cov`, `npm run test:integration` - ---- - -## 8. As-built notes - -*(Filled when the phase completes — record deviations, actual coverage numbers, CI timings.)* \ No newline at end of file