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 diff --git a/src/common/utils/phone.spec.ts b/src/common/utils/phone.spec.ts new file mode 100644 index 0000000..c51487b --- /dev/null +++ b/src/common/utils/phone.spec.ts @@ -0,0 +1,22 @@ +import { maskPhoneNumber } from './phone'; + +describe('maskPhoneNumber', () => { + describe('masking', () => { + it.each([ + ['+989121112233', '+98•••••233'], + ['+98912111233', '+98•••••233'], + ['09121112233', '091•••••233'], + ])('should mask %p as %p', (input, expected) => { + expect(maskPhoneNumber(input)).toBe(expected); + }); + + it('should mask short numbers without a suffix', () => { + expect(maskPhoneNumber('+98912')).toBe('+98•••••'); + }); + + it('should fully mask tiny or empty input', () => { + expect(maskPhoneNumber('+98')).toBe('•••••'); + expect(maskPhoneNumber('')).toBe('•••••'); + }); + }); +}); diff --git a/src/common/utils/phone.ts b/src/common/utils/phone.ts new file mode 100644 index 0000000..d53013b --- /dev/null +++ b/src/common/utils/phone.ts @@ -0,0 +1,20 @@ +/** + * Phone-number masking for public responses (Phase 4 Task 5, S-8). + * + * Keeps the country prefix and the last three digits so UIs can still + * display a recognizable hint without exposing PII: + * `+989121112233` → `+98•••••233`. + */ +const VISIBLE_PREFIX_LENGTH = 3; +const VISIBLE_SUFFIX_LENGTH = 3; +const MASK = '•••••'; + +export function maskPhoneNumber(phoneNumber: string): string { + if (!phoneNumber || phoneNumber.length <= VISIBLE_PREFIX_LENGTH) { + return MASK; + } + if (phoneNumber.length <= VISIBLE_PREFIX_LENGTH + VISIBLE_SUFFIX_LENGTH) { + return `${phoneNumber.slice(0, VISIBLE_PREFIX_LENGTH)}${MASK}`; + } + return `${phoneNumber.slice(0, VISIBLE_PREFIX_LENGTH)}${MASK}${phoneNumber.slice(-VISIBLE_SUFFIX_LENGTH)}`; +} diff --git a/src/modules/map/map.service.spec.ts b/src/modules/map/map.service.spec.ts index 86653d7..21d2165 100644 --- a/src/modules/map/map.service.spec.ts +++ b/src/modules/map/map.service.spec.ts @@ -3,19 +3,18 @@ import { MapService } from './map.service'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; -import { PrismaClient } from '../../../generated/prisma'; -import { PrismaService } from '../prisma/prisma.service'; +import { CityRepository } from '../prisma/repositories/city.repository'; describe('MapService', () => { let service: MapService; let httpService: DeepMockProxy; let configService: DeepMockProxy; - let prismaService: DeepMockProxy; + let cities: DeepMockProxy; beforeEach(async () => { httpService = mockDeep(); configService = mockDeep(); - prismaService = mockDeep(); + cities = mockDeep(); configService.get.mockImplementation((key: string, defaultValue?: any) => { const config = { @@ -30,7 +29,7 @@ describe('MapService', () => { MapService, { provide: HttpService, useValue: httpService }, { provide: ConfigService, useValue: configService }, - { provide: PrismaService, useValue: prismaService }, + { provide: CityRepository, useValue: cities }, ], }).compile(); @@ -40,4 +39,34 @@ describe('MapService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getIntermediateCitiesWithIds', () => { + it('should resolve cities through the repository and delegate', async () => { + const originCity = { id: 'origin', latitude: '35.6', longitude: '51.3' }; + const destinationCity = { + id: 'dest', + latitude: '35.9', + longitude: '51.6', + }; + cities.findCityOrThrow + .mockResolvedValueOnce(originCity as any) + .mockResolvedValueOnce(destinationCity as any); + const inner = jest + .spyOn(service as any, 'getIntermediateCities') + .mockResolvedValue([]); + + const result = await service.getIntermediateCitiesWithIds( + 'origin', + 'dest', + ); + + expect(result).toEqual([]); + expect(cities.findCityOrThrow).toHaveBeenCalledWith('origin'); + expect(cities.findCityOrThrow).toHaveBeenCalledWith('dest'); + expect(inner).toHaveBeenCalledWith( + { latitude: '35.6', longitude: '51.3' }, + { latitude: '35.9', longitude: '51.6' }, + ); + }); + }); }); diff --git a/src/modules/map/map.service.ts b/src/modules/map/map.service.ts index 8b64ee4..187379c 100644 --- a/src/modules/map/map.service.ts +++ b/src/modules/map/map.service.ts @@ -17,8 +17,7 @@ import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; import { AxiosResponse } from 'axios'; import { CityDto } from './dto/city.dto'; -import { formatPrismaError } from '../../common/utilities'; -import { PrismaService } from '../prisma/prisma.service'; +import { CityRepository } from '../prisma/repositories/city.repository'; import { CoordinatesQueryDto } from './coordinates-query.dto'; @Injectable() @@ -29,7 +28,7 @@ export class MapService { constructor( private httpService: HttpService, config: ConfigService, - private prisma: PrismaService, + private cities: CityRepository, ) { this.mapApiKey = config.getOrThrow('MAP_API_KEY'); this.mapApiUrl = config.getOrThrow('MAP_API_URL'); @@ -112,23 +111,9 @@ export class MapService { } async getIntermediateCitiesWithIds(originId: string, destinationId: string) { - const originCity = await this.prisma.city - .findUniqueOrThrow({ - where: { id: originId }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); + const originCity = await this.cities.findCityOrThrow(originId); - const destinationCity = await this.prisma.city - .findUniqueOrThrow({ - where: { id: destinationId }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); + const destinationCity = await this.cities.findCityOrThrow(destinationId); return this.getIntermediateCities( { diff --git a/src/modules/notification/notification-messages.spec.ts b/src/modules/notification/notification-messages.spec.ts new file mode 100644 index 0000000..e223258 --- /dev/null +++ b/src/modules/notification/notification-messages.spec.ts @@ -0,0 +1,65 @@ +import { + getNotificationMessage, + NotificationMessages, +} from './notification-messages'; + +/** + * Placeholder substitution (Phase 4 Task 6). + * + * Locks the current template behavior: templates carry a `#` prefix + * (`#{packageCode}`) but substitution targets single-brace `{key}`, so the + * rendered message keeps the `#` (`#123`). Missing keys are left untouched. + */ +describe('getNotificationMessage', () => { + describe('substitution', () => { + it('should substitute package and trip codes keeping the hash prefix', () => { + expect( + getNotificationMessage(NotificationMessages.PackageCreated, { + packageCode: 123, + }), + ).toBe( + '📦 بسته شما با کد #123 با موفقیت ایجاد شد. حال می‌توانید برای سفرهای متناسب درخواست بفرستید.', + ); + }); + + it('should substitute every placeholder of a multi-key template', () => { + expect( + getNotificationMessage(NotificationMessages.TripRequestAccepted, { + tripCode: 7, + packageCode: 42, + }), + ).toBe('✅ عالی! درخواست سفر #7 برای بسته #42 پذیرفته شد.'); + }); + + it('should substitute single-brace placeholders without hash', () => { + expect( + getNotificationMessage(NotificationMessages.NewTransporterNote, { + transporterName: 'رضا', + packageCode: 5, + noteContent: 'سلام', + }), + ).toBe('📝 سفیر رضا برای بسته #5 یک یادداشت جدید ارسال کرده: سلام.'); + }); + }); + + describe('missing context', () => { + it('should leave missing placeholders untouched', () => { + const message = getNotificationMessage( + NotificationMessages.TripRequestAccepted, + ); + + expect(message).toContain('{tripCode}'); + expect(message).toContain('{packageCode}'); + }); + + it('should substitute only the provided keys', () => { + const message = getNotificationMessage( + NotificationMessages.TripRequestAccepted, + { tripCode: 7 }, + ); + + expect(message).toContain('#7'); + expect(message).toContain('{packageCode}'); + }); + }); +}); diff --git a/src/modules/package/application/package.service.spec.ts b/src/modules/package/application/package.service.spec.ts new file mode 100644 index 0000000..eb6f9db --- /dev/null +++ b/src/modules/package/application/package.service.spec.ts @@ -0,0 +1,426 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PackageService } from './package.service'; +import { MatchingService } from '../matching.service'; +import { TripService } from '../../trip/application/trip.service'; +import { S3Service } from '../../s3/s3.service'; +import { TurfService } from '../../turf/turf.service'; +import { PricingService } from '../../pricing/pricing.service'; +import { MapService } from '../../map/map.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient, PackageStatusEnum } from '../../../../generated/prisma'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + AuthMessages, + BadRequestMessages, +} from '../../../common/enums/messages.enum'; +import { CreatePackageDto } from '../dto/create-package.dto'; +import { UpdatePackageDto } from '../dto/update.package.dto'; +import { NotificationService } from '../../notification/notification.service'; + +describe('PackageService', () => { + let service: PackageService; + let prisma: DeepMockProxy; + let mapService: DeepMockProxy; + let pricingService: DeepMockProxy; + let matchingService: DeepMockProxy; + let tripService: DeepMockProxy; + let s3Service: DeepMockProxy; + let turfService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + + const mockOriginAddress = { + id: 'address-123', + userId: 'user-123', + title: 'خانه', + latitude: 35.6892, + longitude: 51.389, + city: 'تهران', + province: 'تهران', + } as any; + + const mockRecipient = { + id: 'recipient-123', + fullName: 'علی احمدی', + phoneNumber: '+989123456788', + address: { + userId: 'user-123', + title: 'دفتر کار', + city: 'تهران', + province: 'تهران', + street: 'خیابان ولیعصر', + details: 'روبروی ساختمان قدیم', + latitude: '35.7219', + longitude: '51.3347', + postalCode: '1234567890', + }, + } as any; + + const mockPackage = { + id: 'package-123', + senderId: 'user-123', + items: ['کتاب', 'لپ‌تاپ'], + weight: 2000, + dimensions: '30x20x15', + status: PackageStatusEnum.created, + suggestedPrice: 50000, + finalPrice: 50000, + isFragile: false, + isPerishable: false, + pickupAtOrigin: true, + deliveryAtDestination: true, + picturesKey: ['pic1.jpg', 'pic2.jpg'], + originAddress: mockOriginAddress, + recipient: mockRecipient, + deletedAt: null, + } as any; + + const mockTrip = { + id: 'trip-123', + status: 'scheduled', + origin: { latitude: 35.6892, longitude: 51.389 }, + destination: { latitude: 35.7219, longitude: 51.3347 }, + waypoints: [], + normalDistanceKm: 10, + normalDurationMin: 30, + totalDeviationDurationMin: 0, + matchedRequests: [], + } as any; + + const mockBreakdown = { + basePrice: 20000, + distanceCost: 15000, + weightCost: 10000, + specialHandlingCost: 0, + cityPremiumCost: 5000, + }; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + mapService = mockDeep(); + pricingService = mockDeep(); + matchingService = mockDeep(); + tripService = mockDeep(); + s3Service = mockDeep(); + turfService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PackageService, + { provide: PrismaService, useValue: prisma }, + { provide: MapService, useValue: mapService }, + { provide: PricingService, useValue: pricingService }, + { provide: MatchingService, useValue: matchingService }, + { provide: TripService, useValue: tripService }, + { provide: S3Service, useValue: s3Service }, + { provide: TurfService, useValue: turfService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + ], + }).compile(); + + service = module.get(PackageService); + }); + + describe('create', () => { + const packageDto: CreatePackageDto = { + items: ['کتاب'], + originAddressId: 'address-123', + recipientId: 'recipient-123', + weight: 1500, + dimensions: '25x20x10', + isFragile: false, + isPerishable: false, + pickupAtOrigin: true, + deliveryAtDestination: true, + picturesKey: ['pic1.jpg'], + } as any; + + it('should create package successfully', async () => { + prisma.address.findFirst.mockResolvedValue(mockOriginAddress); + prisma.packageRecipient.findFirst.mockResolvedValue(mockRecipient); + prisma.package.create.mockResolvedValue(mockPackage); + + mapService.calculateDistance.mockResolvedValue({ + distance: 15, + duration: 25, + }); + pricingService.calculateSuggestedPrice.mockReturnValue({ + suggestedPrice: 50000, + breakdown: mockBreakdown, + }); + + const result = await service.create('user-123', packageDto); + + expect(result).toEqual(mockPackage); + expect(mapService.calculateDistance).toHaveBeenCalledWith({ + origin: { + latitude: mockOriginAddress.latitude, + longitude: mockOriginAddress.longitude, + }, + destination: { + latitude: mockRecipient.address.latitude, + longitude: mockRecipient.address.longitude, + }, + }); + expect(pricingService.calculateSuggestedPrice).toHaveBeenCalled(); + }); + + it('should throw when origin address not found', async () => { + prisma.address.findFirst.mockResolvedValue(null); + + await expect(service.create('user-123', packageDto)).rejects.toThrow( + new ForbiddenException( + `${AuthMessages.EntityAccessDenied} origin address.`, + ), + ); + }); + + it('should throw when recipient not found', async () => { + prisma.address.findFirst.mockResolvedValue(mockOriginAddress); + prisma.packageRecipient.findFirst.mockResolvedValue(null); + + await expect(service.create('user-123', packageDto)).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} recipient.`), + ); + }); + }); + + describe('getById', () => { + it('should return package with presigned URLs', async () => { + const packageWithUrls = { + ...mockPackage, + picturesKey: ['pic1.jpg', 'pic2.jpg'], + picturesUrl: ['url1', 'url2'], + }; + + prisma.package.findFirstOrThrow.mockResolvedValue(mockPackage); + s3Service.generateGetPresignedUrl + .mockResolvedValueOnce('url1') + .mockResolvedValueOnce('url2'); + + const result = await service.getById('package-123'); + + expect(result).toMatchObject(packageWithUrls); + }); + + it('should handle null picturesKey', async () => { + const packageWithNullPics = { ...mockPackage, picturesKey: null }; + prisma.package.findFirstOrThrow.mockResolvedValue(packageWithNullPics); + + const result = await service.getById('package-123'); + + expect(result.picturesKey).toBeNull(); + expect(result.picturesUrl).toBeUndefined(); + }); + + it('should default to the injected client when no tx is provided', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + ...mockPackage, + picturesKey: null, + }); + + const result = await service.getById('package-123'); + + expect(result.id).toBe('package-123'); + expect(prisma.package.findFirstOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'package-123', deletedAt: null }, + }), + ); + }); + }); + + describe('getAll', () => { + it('should map packages with presigned urls', async () => { + prisma.package.findMany.mockResolvedValue([mockPackage]); + s3Service.generateGetPresignedUrl.mockResolvedValue('url1'); + + const result = await service.getAll('user-123'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'package-123', + picturesUrl: ['url1', 'url1'], + picturesKey: undefined, + }); + }); + + it('should yield undefined entries for non-array picturesKey (known quirk)', async () => { + prisma.package.findMany.mockResolvedValue([ + { ...mockPackage, picturesKey: null }, + ]); + + const result = await service.getAll('user-123'); + + expect(result).toEqual([undefined]); + }); + }); + + describe('update', () => { + const updateDto: UpdatePackageDto = { + finalPrice: 60000, + } as any; + + it('should update package successfully', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.created, + suggestedPrice: 50000, + } as any); + prisma.package.update.mockResolvedValue({ ...mockPackage, ...updateDto }); + + const result = await service.update('package-123', updateDto); + + expect(result).toEqual({ ...mockPackage, ...updateDto }); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123' }, + data: updateDto, + }); + }); + + it('should throw when package status is invalid', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.matched, + suggestedPrice: 50000, + } as any); + + await expect(service.update('package-123', updateDto)).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, + ), + ); + }); + + it('should throw when final price is below suggested price', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.created, + suggestedPrice: 50000, + } as any); + + await expect( + service.update('package-123', { finalPrice: 40000 } as any), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.InvalidPrice), + ); + }); + }); + + describe('delete', () => { + it('should soft-delete package successfully', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.created, + } as any); + prisma.package.update.mockResolvedValue({ + ...mockPackage, + deletedAt: new Date(), + }); + + const result = await service.delete('package-123'); + + expect(result.deletedAt).toBeInstanceOf(Date); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123' }, + data: { deletedAt: expect.any(Date) }, + }); + }); + + it('should throw when package status is invalid', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + + await expect(service.delete('package-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, + ), + ); + }); + }); + + describe('getMatchedTrips', () => { + const session = { + packages: [], + } as any; + + it('should return matched trips with deviation info', async () => { + const matchResults = [ + { + tripId: 'trip-123', + isRequestSent: false, + score: 100, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + }, + ]; + + service.getById = jest.fn().mockResolvedValue(mockPackage); + matchingService.findMatchedTrips.mockResolvedValue(matchResults); + tripService.getMultipleById.mockResolvedValue([mockTrip]); + turfService.sortLocationsByRoute.mockReturnValue([] as any); + mapService.calculateDistance.mockResolvedValue({ + distance: 12, + duration: 35, + }); + pricingService.calculateDeviationCost.mockReturnValue(5000); + + const result = await service.getMatchedTrips('package-123', session); + + expect(result).toHaveLength(1); + expect(result[0]?.additionalPrice).toBe(5000); + }); + + it('should throw when package status is invalid', async () => { + const invalidPackage = { + ...mockPackage, + status: PackageStatusEnum.matched, + }; + + service.getById = jest.fn().mockResolvedValue(invalidPackage); + + await expect( + service.getMatchedTrips('package-123', session), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestPackage), + ); + }); + }); + + describe('generatePackagePicPresignedUrl', () => { + it('should resolve presigned urls for string keys', async () => { + s3Service.generateGetPresignedUrl.mockResolvedValue('url1'); + + const result = await service.generatePackagePicPresignedUrl(['pic1.jpg']); + + expect(result).toEqual(['url1']); + expect(s3Service.generateGetPresignedUrl).toHaveBeenCalledWith( + 'pic1.jpg', + ); + }); + + it('should log and return empty string when key extraction fails', async () => { + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + s3Service.generateGetPresignedUrl.mockImplementation(() => { + throw new Error('S3 down'); + }); + + const result = await service.generatePackagePicPresignedUrl(['pic1.jpg']); + + expect(result).toEqual(['']); + expect(loggerSpy).toHaveBeenCalledWith( + 'Failed to generate presigned URL for picturesKey[0]:', + expect.any(Error), + ); + }); + }); +}); diff --git a/src/modules/package/application/package.service.ts b/src/modules/package/application/package.service.ts new file mode 100644 index 0000000..21c2e1c --- /dev/null +++ b/src/modules/package/application/package.service.ts @@ -0,0 +1,508 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CreatePackageDto } from '../dto/create-package.dto'; +import { + AuthMessages, + BadRequestMessages, +} from '../../../common/enums/messages.enum'; +import { PackageStatusEnum } from '../../../../generated/prisma'; +import { MapService } from '../../map/map.service'; +import { PricingService } from '../../pricing/pricing.service'; +import { S3Service } from '../../s3/s3.service'; +import { SessionData } from 'express-session'; +import { MatchingService } from '../matching.service'; +import { TripService } from '../../trip/application/trip.service'; +import { PrismaTransaction } from '../../prisma/prisma.types'; +import { UpdatePackageDto } from '../dto/update.package.dto'; +import { instanceToPlain } from 'class-transformer'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; + +/** + * Package lifecycle + CRUD orchestration (Phase 4 Task 5). + * + * Slimmed from the 22 KB god service: recipients live in + * `RecipientService`, package-side requests in `PackageRequestService`, + * public tracking in `TrackingService`. All multi-statement writes run + * through `TransactionRunner` (no manual `.catch` — the global filter maps + * Prisma errors). Fixes vs the original: `update`/`delete` now write + * through `tx` (they used the root client inside the transaction), and + * `generatePackagePicPresignedUrl` uses Nest `Logger` instead of + * `console.error` and no longer imports the internal + * `generated/prisma/runtime/library` `JsonArray` type (B-9). + * + * Known quirk preserved: `getAll` yields `undefined` entries for packages + * whose `picturesKey` is not an array. + */ +@Injectable() +export class PackageService { + private readonly logger = new Logger(PackageService.name); + + constructor( + private prisma: PrismaService, + private mapService: MapService, + private pricingService: PricingService, + private matchingService: MatchingService, + private tripService: TripService, + private s3Service: S3Service, + private turfService: TurfService, + private notificationService: NotificationService, + private runner: TransactionRunner, + ) {} + + async create( + userId: string, + { items, originAddressId, recipientId, ...packageDto }: CreatePackageDto, + ) { + return this.runner.run(async (tx) => { + const originAddress = await tx.address.findFirst({ + where: { + id: originAddressId, + userId, + }, + }); + + if (!originAddress) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} origin address.`, + ); + } + + const recipient = await tx.packageRecipient.findFirst({ + where: { + id: recipientId, + address: { + userId, + }, + }, + include: { + address: true, + }, + }); + + if (!recipient) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} recipient.`, + ); + } + + // Calculate distance for pricing + const { distance } = await this.mapService.calculateDistance({ + origin: { + latitude: originAddress.latitude, + longitude: originAddress.longitude, + }, + destination: { + latitude: recipient.address.latitude, + longitude: recipient.address.longitude, + }, + }); + + const { suggestedPrice, breakdown } = + this.pricingService.calculateSuggestedPrice({ + distanceKm: distance, + weightGr: packageDto.weight, + isFragile: packageDto.isFragile ?? false, + isPerishable: packageDto.isPerishable ?? false, + originCity: originAddress.city!, + destinationCity: recipient.address.city!, + }); + + const plainBreakdown = instanceToPlain(breakdown); + + const packageData = await tx.package.create({ + data: { + senderId: userId, + items, + originAddressId: originAddress.id, + recipientId: recipient.id, + ...packageDto, + suggestedPrice, + breakdown: plainBreakdown, + finalPrice: suggestedPrice, + }, + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + }, + }); + + // Add create package notification + await this.notificationService.create( + userId, + { + packageId: packageData.id, + content: getNotificationMessage(NotificationMessages.PackageCreated, { + packageCode: packageData.code, + }), + }, + tx, + ); + + return packageData; + }); + } + + async getById(id: string, tx: PrismaTransaction = this.prisma) { + const packageData = await tx.package.findFirstOrThrow({ + where: { + id, + deletedAt: null, + }, + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + matchedRequest: { + select: { + trip: { + select: { + transporter: { + select: { + rate: true, + user: { + select: { + firstName: true, + lastName: true, + gender: true, + phoneNumber: true, + }, + }, + }, + }, + vehicle: { + select: { + vehicleType: true, + model: { + select: { + brand: true, + }, + }, + manufactureYear: true, + color: true, + }, + }, + departureTime: true, + status: true, + description: true, + }, + }, + trackingCode: true, + deliveryCode: true, + transporterNotes: true, + senderRating: true, + senderComment: true, + isCompleted: true, + pickupTime: true, + deliveryTime: true, + paymentStatus: true, + }, + }, + }, + }); + + const keys = packageData.picturesKey; + let picturesUrl: string[] | undefined; + if (keys && Array.isArray(keys)) { + picturesUrl = await this.generatePackagePicPresignedUrl(keys); + } + + return { + ...packageData, + picturesUrl, + }; + } + + async getAll( + userId: string, + status: PackageStatusEnum[] = [ + PackageStatusEnum.created, + PackageStatusEnum.searching_transporter, + PackageStatusEnum.matched, + PackageStatusEnum.picked_up, + PackageStatusEnum.in_transit, + ], + page = 1, + limit = 10, + ) { + const skip = (page - 1) * limit; + const packages = await this.prisma.package.findMany({ + where: { + senderId: userId, + status: { + in: status, + }, + deletedAt: null, + }, + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + }); + + return Promise.all( + packages.map(async (packageData) => { + const keys = packageData.picturesKey; + if (keys && Array.isArray(keys)) { + return { + ...packageData, + picturesUrl: await this.generatePackagePicPresignedUrl(keys), + picturesKey: undefined, + }; + } + }), + ); + } + + async update(id: string, packageDto: UpdatePackageDto) { + return this.runner.run(async (tx) => { + const { status, suggestedPrice } = await tx.package.findFirstOrThrow({ + where: { + id, + deletedAt: null, + }, + select: { + status: true, + suggestedPrice: true, + }, + }); + + const isValidStatus = + status === PackageStatusEnum.created || + status === PackageStatusEnum.searching_transporter; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${status}.`, + ); + } + + if (packageDto.finalPrice < suggestedPrice) { + throw new BadRequestException(BadRequestMessages.InvalidPrice); + } + + return tx.package.update({ + where: { id }, + data: packageDto, + }); + }); + } + + async generatePackagePicPresignedUrl(keys: unknown[]) { + return Promise.all( + keys.map(async (key, index) => { + const keyString = JSON.stringify(key).split('"')[1]; + try { + if (keyString) { + return this.s3Service.generateGetPresignedUrl(keyString); + } + return ''; + } catch (urlError) { + this.logger.error( + `Failed to generate presigned URL for picturesKey[${index}]:`, + urlError, + ); + return ''; + } + }), + ); + } + + private async updateStatus( + id: string, + status: PackageStatusEnum, + tx: PrismaTransaction = this.prisma, + ) { + return tx.package.update({ + where: { + id, + deletedAt: null, + }, + data: { + status, + }, + }); + } + + async delete(id: string) { + return this.runner.run(async (tx) => { + const { status } = await tx.package.findFirstOrThrow({ + where: { id }, + select: { status: true }, + }); + + const isValidStatus = + status === PackageStatusEnum.created || + status === PackageStatusEnum.searching_transporter; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${status}.`, + ); + } + + return tx.package.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + }); + } + + async getMatchedTrips( + packageId: string, + session: SessionData, + maxResults = 20, + ) { + return this.runner.run(async (tx) => { + const packageData = await this.getById(packageId, tx); + + const idValidPackageStatus = + packageData.status === PackageStatusEnum.created || + packageData.status === PackageStatusEnum.searching_transporter || + packageData.status === PackageStatusEnum.cancelled; + if (!idValidPackageStatus) { + throw new BadRequestException(BadRequestMessages.SendRequestPackage); + } + + // Update package status + await this.updateStatus( + packageId, + PackageStatusEnum.searching_transporter, + tx, + ); + + // Do matching + const matchedTrips = await this.matchingService.findMatchedTrips( + packageData, + session, + maxResults, + tx, + ); + + // Fetch trips + const tripIds = matchedTrips + .filter((m) => !m.isRequestSent) + .slice(0, maxResults) + .map((m) => m.tripId); + const trips = await this.tripService.getMultipleById(tripIds, tx); + const tripMap = new Map(trips.map((trip) => [trip.id, trip])); + + // Calculate deviation info + const matchingResult = await Promise.all( + matchedTrips.map(async (matchedTrip) => { + const trip = tripMap.get(matchedTrip.tripId); + if (!trip) return; + + const waypoints = trip.matchedRequests + .flatMap((r) => [ + r.package.pickupAtOrigin + ? { + latitude: r.package.originAddress.latitude, + longitude: r.package.originAddress.longitude, + } + : undefined, + r.package.deliveryAtDestination + ? { + latitude: r.package.recipient.address.latitude, + longitude: r.package.recipient.address.longitude, + } + : undefined, + ]) + .filter((v) => v !== undefined); + + waypoints.push( + ...[ + packageData.pickupAtOrigin + ? { + latitude: packageData.originAddress.latitude, + longitude: packageData.originAddress.longitude, + } + : undefined, + packageData.deliveryAtDestination + ? { + latitude: packageData.recipient.address.latitude, + longitude: packageData.recipient.address.longitude, + } + : undefined, + ].filter((v) => v !== undefined), + ); + + const sortedWaypoints = this.turfService.sortLocationsByRoute( + trip.origin, + trip.destination, + waypoints, + ); + + const { distance, duration } = + await this.mapService.calculateDistance({ + origin: { + latitude: trip.origin.latitude, + longitude: trip.origin.longitude, + }, + destination: { + latitude: trip.destination.latitude, + longitude: trip.destination.longitude, + }, + waypoints: sortedWaypoints, + }); + + const deviationDistance = Math.max( + 0, + distance - + ((trip.normalDistanceKm ?? 0) + + (trip.totalDeviationDurationMin ?? 0)), + ); + const deviationDuration = Math.max( + 0, + duration - + ((trip.normalDurationMin ?? 0) + + (trip.totalDeviationDurationMin ?? 0)), + ); + + const additionalPrice = this.pricingService.calculateDeviationCost( + deviationDistance, + deviationDuration, + ); + matchedTrip.deviationInfo = { + distance: deviationDistance, + duration: deviationDuration, + additionalPrice, + }; + + return { + ...trip, + additionalPrice, + }; + }), + ); + + return matchingResult.filter(Boolean); + }); + } +} diff --git a/src/modules/package/domain/package-request.service.spec.ts b/src/modules/package/domain/package-request.service.spec.ts new file mode 100644 index 0000000..7d9a3ce --- /dev/null +++ b/src/modules/package/domain/package-request.service.spec.ts @@ -0,0 +1,387 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PackageRequestService } from './package-request.service'; +import { PricingService } from '../../pricing/pricing.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { + PrismaClient, + PackageStatusEnum, + RequestStatusEnum, + TripStatusEnum, +} from '../../../../generated/prisma'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { + AuthMessages, + BadRequestMessages, + NotFoundMessages, +} from '../../../common/enums/messages.enum'; +import { CreateRequestDto } from '../../trip/dto/create-request.dto'; +import { NotificationService } from '../../notification/notification.service'; + +describe('PackageRequestService', () => { + let service: PackageRequestService; + let prisma: DeepMockProxy; + let pricingService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + + const mockPackage = { + id: 'package-123', + senderId: 'user-123', + status: PackageStatusEnum.searching_transporter, + finalPrice: 50000, + code: 1001, + } as any; + + const mockTrip = { + id: 'trip-123', + status: TripStatusEnum.scheduled, + code: 2001, + } as any; + + function sessionWithMatch(isRequestSent = false) { + return { + packages: [ + { + id: 'package-123', + matchResults: [ + { + tripId: 'trip-123', + isRequestSent, + score: 100, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + deviationInfo: { + distance: 2, + duration: 5, + additionalPrice: 3000, + }, + }, + ], + }, + ], + } as any; + } + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + pricingService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PackageRequestService, + { provide: PrismaService, useValue: prisma }, + { provide: PricingService, useValue: pricingService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + ], + }).compile(); + + service = module.get(PackageRequestService); + }); + + describe('createRequest', () => { + const requestDto: CreateRequestDto = { + packageId: 'package-123', + tripId: 'trip-123', + senderNote: 'لطفا با دقت حمل کنید', + }; + + it('should create request successfully', async () => { + const createdRequest = { + id: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + status: RequestStatusEnum.pending, + offeredPrice: 45000, + deviationCost: 3000, + }; + const session = sessionWithMatch(); + + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + prisma.tripRequest.create.mockResolvedValue(createdRequest as any); + pricingService.calculateTransporterEarnings.mockReturnValue(42000); + + const result = await service.createRequest( + 'user-123', + requestDto, + session, + ); + + expect(result).toEqual(createdRequest); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(true); + expect(prisma.tripRequest.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + packageId: 'package-123', + tripId: 'trip-123', + deviationDistanceKm: 2, + deviationDurationMin: 5, + deviationCost: 3000, + offeredPrice: 45000, + }), + }); + }); + + it('should throw when user does not own package', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue({ + ...mockPackage, + senderId: 'other-user', + }); + + await expect( + service.createRequest('user-123', requestDto, sessionWithMatch()), + ).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), + ); + }); + + it('should throw when package status is invalid', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue({ + ...mockPackage, + status: PackageStatusEnum.matched, + }); + + await expect( + service.createRequest('user-123', requestDto, sessionWithMatch()), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestPackage), + ); + }); + + it('should throw when trip status is invalid', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.completed, + }); + + await expect( + service.createRequest('user-123', requestDto, sessionWithMatch()), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestTrip), + ); + }); + + it('should throw when no matched trips found', async () => { + const emptySession = { + packages: [], + } as any; + + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + + await expect( + service.createRequest('user-123', requestDto, emptySession), + ).rejects.toThrow(new NotFoundException(NotFoundMessages.MatchedTrip)); + }); + + it('should throw when trip is not in matched trips', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + + await expect( + service.createRequest( + 'user-123', + { ...requestDto, tripId: 'trip-unknown' }, + sessionWithMatch(), + ), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestTrip), + ); + }); + + it('should not touch the session when the transaction fails (A-4)', async () => { + const session = sessionWithMatch(); + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + prisma.tripRequest.create.mockResolvedValue({ id: 'request-123' } as any); + pricingService.calculateTransporterEarnings.mockReturnValue(42000); + notificationService.create.mockRejectedValue( + new Error('Redis/notification down'), + ); + + await expect( + service.createRequest('user-123', requestDto, session), + ).rejects.toThrow('Redis/notification down'); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(false); + }); + }); + + describe('getAllPackageRequests', () => { + it('should return all package requests', async () => { + const requests = [ + { + id: 'request-1', + packageId: 'package-123', + status: RequestStatusEnum.pending, + }, + { + id: 'request-2', + packageId: 'package-123', + status: RequestStatusEnum.accepted, + }, + ]; + + prisma.tripRequest.findMany.mockResolvedValue(requests as any); + + const result = await service.getAllPackageRequests('package-123'); + + expect(result).toEqual(requests); + expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ + where: { + packageId: 'package-123', + status: { + in: Object.values(RequestStatusEnum), + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + + it('should filter by status', async () => { + const pendingRequests = [ + { + id: 'request-1', + packageId: 'package-123', + status: RequestStatusEnum.pending, + }, + ]; + + prisma.tripRequest.findMany.mockResolvedValue(pendingRequests as any); + + await service.getAllPackageRequests('package-123', [ + RequestStatusEnum.pending, + ]); + + expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ + where: { + packageId: 'package-123', + status: { + in: [RequestStatusEnum.pending], + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + }); + + describe('updateRequest', () => { + it('should cancel request and update session', async () => { + const canceledRequest = { + id: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + status: RequestStatusEnum.canceled, + }; + + const session = { + packages: [ + { + id: 'package-123', + matchResults: [ + { + tripId: 'trip-123', + isRequestSent: true, + score: 100, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + }, + ], + }, + ], + } as any; + + prisma.tripRequest.update.mockResolvedValue({ + ...canceledRequest, + package: { id: 'package-123', senderId: 'user-123' }, + } as any); + + const result = await service.updateRequest('request-123', session); + + expect(result).toEqual(canceledRequest); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(false); + expect(prisma.tripRequest.update).toHaveBeenCalledWith({ + where: { + id: 'request-123', + status: RequestStatusEnum.pending, + }, + data: { + status: RequestStatusEnum.canceled, + }, + include: { + package: { + select: { + id: true, + senderId: true, + }, + }, + }, + }); + }); + + it('should handle missing session data gracefully', async () => { + const canceledRequest = { + id: 'request-123', + packageId: 'package-456', + tripId: 'trip-789', + status: RequestStatusEnum.canceled, + }; + + const session = { + packages: [], + } as any; + + prisma.tripRequest.update.mockResolvedValue({ + ...canceledRequest, + package: { id: 'package-456', senderId: 'user-456' }, + } as any); + + const result = await service.updateRequest('request-123', session); + + expect(result).toEqual(canceledRequest); + }); + + it('should not touch the session when the transaction fails (A-4)', async () => { + const session = { + packages: [ + { + id: 'package-123', + matchResults: [{ tripId: 'trip-123', isRequestSent: true }], + }, + ], + } as any; + + prisma.tripRequest.update.mockResolvedValue({ + id: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + status: RequestStatusEnum.canceled, + package: { id: 'package-123', senderId: 'user-123' }, + } as any); + notificationService.create.mockRejectedValue(new Error('Notify down')); + + await expect( + service.updateRequest('request-123', session), + ).rejects.toThrow('Notify down'); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(true); + }); + }); +}); diff --git a/src/modules/package/domain/package-request.service.ts b/src/modules/package/domain/package-request.service.ts new file mode 100644 index 0000000..e1a1670 --- /dev/null +++ b/src/modules/package/domain/package-request.service.ts @@ -0,0 +1,201 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { + AuthMessages, + BadRequestMessages, + NotFoundMessages, +} from '../../../common/enums/messages.enum'; +import { + PackageStatusEnum, + RequestStatusEnum, + TripStatusEnum, +} from '../../../../generated/prisma'; +import { MatchResult } from '../matching.types'; +import { SessionData } from 'express-session'; +import { PricingService } from '../../pricing/pricing.service'; +import { CreateRequestDto } from '../../trip/dto/create-request.dto'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; + +/** + * Package-side trip requests (Phase 4 Task 5). + * + * Owns `createRequest`, `getAllPackageRequests` and `updateRequest` + * (= cancel). Session bookkeeping (`isRequestSent`) happens **after** the + * transaction commits: the Redis session is not transactional with + * Postgres, so mutating it inside the tx risks divergence on rollback + * (A-4 mitigation). + */ +@Injectable() +export class PackageRequestService { + constructor( + private prisma: PrismaService, + private pricingService: PricingService, + private notificationService: NotificationService, + private runner: TransactionRunner, + ) {} + + async createRequest( + userId: string, + { packageId, tripId, senderNote }: CreateRequestDto, + session: SessionData, + ) { + let matchedTrip: MatchResult | undefined; + const request = await this.runner.run(async (tx) => { + const packageData = await tx.package.findUniqueOrThrow({ + where: { id: packageId }, + }); + + if (userId !== packageData.senderId) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} package.`, + ); + } + + if (packageData.status !== PackageStatusEnum.searching_transporter) { + throw new BadRequestException(BadRequestMessages.SendRequestPackage); + } + + const tripData = await tx.trip.findUniqueOrThrow({ + where: { id: tripId }, + include: { + origin: true, + destination: true, + waypoints: true, + }, + }); + + if (tripData.status !== TripStatusEnum.scheduled) { + throw new BadRequestException(BadRequestMessages.SendRequestTrip); + } + + const matchedTrips = session.packages.find( + (p) => p.id === packageId, + )?.matchResults; + if (!matchedTrips || !matchedTrips.length) { + throw new NotFoundException(NotFoundMessages.MatchedTrip); + } + + matchedTrip = matchedTrips.find((t) => t.tripId === tripId); + if (!matchedTrip) { + throw new BadRequestException(BadRequestMessages.SendRequestTrip); + } + + const deviationDistance = matchedTrip.deviationInfo?.distance ?? 0; + const deviationDuration = matchedTrip.deviationInfo?.duration ?? 0; + const deviationCost = matchedTrip.deviationInfo?.additionalPrice ?? 0; + const transporterEarnings = + this.pricingService.calculateTransporterEarnings( + packageData.finalPrice, + deviationCost, + ); + const offeredPrice = transporterEarnings + deviationCost; + + const created = await tx.tripRequest.create({ + data: { + packageId, + tripId, + deviationDistanceKm: deviationDistance, + deviationDurationMin: deviationDuration, + deviationCost, + offeredPrice, + senderNote, + }, + }); + + // Add create request notification + await this.notificationService.create( + userId, + { + packageId, + content: getNotificationMessage( + NotificationMessages.TripRequestCreated, + { + packageCode: packageData.code, + tripCode: tripData.code, + }, + ), + }, + tx, + ); + + return created; + }); + + // Update session only after the transaction commits (see class doc). + if (matchedTrip) matchedTrip.isRequestSent = true; + + return request; + } + + async getAllPackageRequests( + packageId: string, + status: RequestStatusEnum[] = Object.values(RequestStatusEnum), + ) { + return this.prisma.tripRequest.findMany({ + where: { + packageId, + status: { + in: status, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async updateRequest(requestId: string, session: SessionData) { + let matchedTrip: MatchResult | undefined; + const request = await this.runner.run(async (tx) => { + const { package: packageData, ...updated } = await tx.tripRequest.update({ + where: { + id: requestId, + status: RequestStatusEnum.pending, + }, + data: { + status: RequestStatusEnum.canceled, + }, + include: { + package: { + select: { + id: true, + senderId: true, + }, + }, + }, + }); + + // Resolve the session entry inside the tx (read-only) but mutate it + // only after commit (see class doc). + matchedTrip = session.packages + .find((p) => p.id === updated.packageId) + ?.matchResults.find((m) => m.tripId === updated.tripId); + + // Add create package notification + await this.notificationService.create( + packageData.senderId, + { + packageId: packageData.id, + content: NotificationMessages.TripRequestCanceled, + }, + tx, + ); + + return updated; + }); + + if (matchedTrip) matchedTrip.isRequestSent = false; + + return request; + } +} diff --git a/src/modules/package/domain/recipient.service.spec.ts b/src/modules/package/domain/recipient.service.spec.ts new file mode 100644 index 0000000..78516f7 --- /dev/null +++ b/src/modules/package/domain/recipient.service.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RecipientService } from './recipient.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CityRepository } from '../../prisma/repositories/city.repository'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '../../../../generated/prisma'; +import { CreateRecipientDto } from '../dto/create-recipient.dto'; + +describe('RecipientService', () => { + let service: RecipientService; + let prisma: DeepMockProxy; + let runner: DeepMockProxy; + let cities: DeepMockProxy; + + const mockCity = { + id: 'city-123', + name: 'تهران', + province: { + id: 'province-123', + name: 'تهران', + }, + } as any; + + const recipientDto: CreateRecipientDto = { + fullName: 'علی احمدی', + phoneNumber: '+989123456788', + address: { + cityId: 'city-123', + title: 'دفتر کار', + street: 'خیابان ولیعصر', + details: 'روبروی ساختمان قدیم', + latitude: '35.7219', + longitude: '51.3347', + postalCode: '1234567890', + }, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + runner = mockDeep(); + cities = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RecipientService, + { provide: PrismaService, useValue: prisma }, + { provide: TransactionRunner, useValue: runner }, + { provide: CityRepository, useValue: cities }, + ], + }).compile(); + + service = module.get(RecipientService); + }); + + describe('createRecipient', () => { + it('should create recipient with denormalized city and province', async () => { + const created = { id: 'recipient-123', ...recipientDto }; + cities.findCityWithProvince.mockResolvedValue(mockCity); + prisma.packageRecipient.create.mockResolvedValue(created as any); + + const result = await service.createRecipient('user-123', recipientDto); + + expect(result).toEqual(created); + expect(cities.findCityWithProvince).toHaveBeenCalledWith( + 'city-123', + prisma, + ); + expect(prisma.packageRecipient.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + address: expect.objectContaining({ + create: expect.objectContaining({ + userId: 'user-123', + province: 'تهران', + city: 'تهران', + }), + }), + }), + include: { address: true }, + }); + }); + + it('should fall back to fullName when address title is missing', async () => { + const dtoWithoutTitle = { + ...recipientDto, + address: { ...recipientDto.address, title: undefined }, + } as any; + cities.findCityWithProvince.mockResolvedValue(mockCity); + prisma.packageRecipient.create.mockResolvedValue({} as any); + + await service.createRecipient('user-123', dtoWithoutTitle); + + expect(prisma.packageRecipient.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + address: expect.objectContaining({ + create: expect.objectContaining({ title: 'علی احمدی' }), + }), + }), + include: { address: true }, + }); + }); + + it('should run creation inside TransactionRunner', async () => { + cities.findCityWithProvince.mockResolvedValue(mockCity); + prisma.packageRecipient.create.mockResolvedValue({} as any); + + await service.createRecipient('user-123', recipientDto); + + expect(runner.run).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAllRecipients', () => { + it('should list highlighted recipients by default', async () => { + prisma.packageRecipient.findMany.mockResolvedValue([]); + + await service.getAllRecipients('user-123'); + + expect(prisma.packageRecipient.findMany).toHaveBeenCalledWith({ + where: { + address: { userId: 'user-123' }, + isHighlighted: true, + OR: [ + { fullName: { contains: undefined, mode: 'insensitive' } }, + { + address: { title: { contains: undefined, mode: 'insensitive' } }, + }, + ], + }, + include: { address: true }, + }); + }); + + it('should search by fullName and address title', async () => { + prisma.packageRecipient.findMany.mockResolvedValue([]); + + await service.getAllRecipients('user-123', 'علی', false); + + expect(prisma.packageRecipient.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isHighlighted: false, + OR: [ + { fullName: { contains: 'علی', mode: 'insensitive' } }, + { + address: { title: { contains: 'علی', mode: 'insensitive' } }, + }, + ], + }), + }), + ); + }); + }); +}); diff --git a/src/modules/package/domain/recipient.service.ts b/src/modules/package/domain/recipient.service.ts new file mode 100644 index 0000000..f0c2501 --- /dev/null +++ b/src/modules/package/domain/recipient.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CityRepository } from '../../prisma/repositories/city.repository'; +import { CreateRecipientDto } from '../dto/create-recipient.dto'; + +/** + * Recipient + address management (Phase 4 Task 5). + * + * Owns `createRecipient` (city → province resolution with address creation + * in one `TransactionRunner` op) and `getAllRecipients`. No manual + * `.catch` — the global filter maps Prisma errors. + */ +@Injectable() +export class RecipientService { + constructor( + private prisma: PrismaService, + private runner: TransactionRunner, + private cities: CityRepository, + ) {} + + async createRecipient( + userId: string, + { address: { cityId, ...address }, ...recipientDto }: CreateRecipientDto, + ) { + return this.runner.run(async (tx) => { + const city = await this.cities.findCityWithProvince(cityId, tx); + + return tx.packageRecipient.create({ + data: { + ...recipientDto, + address: { + create: { + userId, + ...address, + title: address.title ?? recipientDto.fullName, + province: city.province.name, + city: city.name, + }, + }, + }, + include: { + address: true, + }, + }); + }); + } + + async getAllRecipients( + userId: string, + search?: string, + isHighlighted = true, + ) { + return this.prisma.packageRecipient.findMany({ + where: { + address: { + userId, + }, + isHighlighted, + OR: [ + { + fullName: { + contains: search, + mode: 'insensitive', + }, + }, + { + address: { + title: { + contains: search, + mode: 'insensitive', + }, + }, + }, + ], + }, + include: { + address: true, + }, + }); + } +} diff --git a/src/modules/package/domain/tracking.service.spec.ts b/src/modules/package/domain/tracking.service.spec.ts new file mode 100644 index 0000000..3186c5d --- /dev/null +++ b/src/modules/package/domain/tracking.service.spec.ts @@ -0,0 +1,121 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TrackingService } from './tracking.service'; +import { S3Service } from '../../s3/s3.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '../../../../generated/prisma'; + +describe('TrackingService', () => { + let service: TrackingService; + let prisma: DeepMockProxy; + let s3Service: DeepMockProxy; + + const mockMatchedRequest = { + id: 'matched-123', + tripId: 'trip-123', + packageId: 'package-123', + trackingCode: '17571445988911932924', + deliveryCode: '12345', + trackingUpdates: [{ city: 'Tehran', createdAt: new Date() }], + package: { + code: 1001, + sender: { + firstName: 'Ahmad', + lastName: 'Mohammadi', + phoneNumber: '+989123456789', + }, + recipient: { + address: { city: 'Tehran' }, + }, + items: ['Books'], + weight: 2000, + dimensions: '30x20x15', + finalPrice: 50000, + breakdown: {}, + status: 'delivered', + packageValue: 100000, + deliveryAtDestination: true, + }, + trip: { + transporter: { + rate: 4.5, + profilePictureKey: 'profile-pic-key', + user: { + firstName: 'Reza', + lastName: 'Ahmadi', + phoneNumber: '+989123456780', + }, + }, + vehicle: { + vehicleType: 'truck', + model: { brand: { name: 'Volvo' } }, + }, + }, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + s3Service = mockDeep(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrackingService, + { provide: PrismaService, useValue: prisma }, + { provide: S3Service, useValue: s3Service }, + ], + }).compile(); + + service = module.get(TrackingService); + }); + + describe('getTrackingByCode', () => { + it('should get trip tracking by code successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + s3Service.generateGetPresignedUrl.mockResolvedValue( + 'https://s3.example.com/profile.jpg', + ); + + const result = await service.getTrackingByCode('TRK123456789'); + + expect(result.trackingUpdates).toBeDefined(); + expect(result.package).toBeDefined(); + expect(result.transporter).toBeDefined(); + expect(result.transporter.profilePictureUrl).toBe( + 'https://s3.example.com/profile.jpg', + ); + }); + + it('should mask the sender phone number (S-8)', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + s3Service.generateGetPresignedUrl.mockResolvedValue( + 'https://s3.example.com/profile.jpg', + ); + + const result = await service.getTrackingByCode('TRK123456789'); + + expect(result.package.sender.phoneNumber).toBe('+98•••••789'); + expect(result.package.sender.phoneNumber).not.toContain('989123456789'); + }); + + it('should query by tracking code with non-deleted filter', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + s3Service.generateGetPresignedUrl.mockResolvedValue('url'); + + await service.getTrackingByCode('TRK123456789'); + + expect(prisma.matchedRequest.findUniqueOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + where: { trackingCode: 'TRK123456789', deletedAt: null }, + }), + ); + }); + }); +}); diff --git a/src/modules/package/domain/tracking.service.ts b/src/modules/package/domain/tracking.service.ts new file mode 100644 index 0000000..387fddd --- /dev/null +++ b/src/modules/package/domain/tracking.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { S3Service } from '../../s3/s3.service'; +import { maskPhoneNumber } from '../../../common/utils/phone'; + +/** + * Public package tracking (Phase 4 Task 5). + * + * Owns `getTrackingByCode`. S-8 fix: the sender phone number is masked + * (`+98•••••123`) instead of exposed in clear — this endpoint is public + * (no auth guard). The transporter phone is left as-is (residual PII, + * follow-up). + */ +@Injectable() +export class TrackingService { + constructor( + private prisma: PrismaService, + private s3Service: S3Service, + ) {} + + async getTrackingByCode(trackingCode: string) { + const matchedRequest = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + trackingCode, + deletedAt: null, + }, + include: { + trackingUpdates: { + orderBy: { + createdAt: 'desc', + }, + }, + package: { + select: { + code: true, + sender: { + select: { + firstName: true, + lastName: true, + phoneNumber: true, + }, + }, + recipient: { + include: { + address: true, + }, + }, + items: true, + weight: true, + dimensions: true, + finalPrice: true, + breakdown: true, + status: true, + packageValue: true, + deliveryAtDestination: true, + }, + }, + trip: { + select: { + transporter: { + select: { + profilePictureKey: true, + rate: true, + user: { + select: { + firstName: true, + lastName: true, + phoneNumber: true, + }, + }, + }, + }, + vehicle: { + select: { + vehicleType: true, + model: { + select: { + brand: true, + }, + }, + manufactureYear: true, + color: true, + }, + }, + }, + }, + }, + }); + + return { + trackingUpdates: matchedRequest.trackingUpdates, + package: { + ...matchedRequest.package, + sender: { + ...matchedRequest.package.sender, + phoneNumber: maskPhoneNumber( + matchedRequest.package.sender.phoneNumber ?? '', + ), + }, + }, + transporter: { + profilePictureUrl: await this.s3Service.generateGetPresignedUrl( + matchedRequest.trip.transporter.profilePictureKey, + ), + ...matchedRequest.trip.transporter, + ...matchedRequest.trip.transporter.user, + vehicle: matchedRequest.trip.vehicle, + }, + }; + } +} diff --git a/src/modules/package/package.controller.spec.ts b/src/modules/package/package.controller.spec.ts index fb1d786..93252ab 100644 --- a/src/modules/package/package.controller.spec.ts +++ b/src/modules/package/package.controller.spec.ts @@ -1,25 +1,33 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PackageController } from './package.controller'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { PackageService } from './package.service'; -import { MatchingService } from './matching.service'; +import { PackageService } from './application/package.service'; +import { RecipientService } from './domain/recipient.service'; +import { PackageRequestService } from './domain/package-request.service'; +import { TrackingService } from './domain/tracking.service'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { OwnershipGuard } from '../auth/guard/ownership.guard'; describe('PackageController', () => { let controller: PackageController; let service: DeepMockProxy; - let matchingService: DeepMockProxy; + let recipientService: DeepMockProxy; + let requestService: DeepMockProxy; + let trackingService: DeepMockProxy; beforeEach(async () => { service = mockDeep(); - matchingService = mockDeep(); + recipientService = mockDeep(); + requestService = mockDeep(); + trackingService = mockDeep(); const module: TestingModule = await Test.createTestingModule({ controllers: [PackageController], providers: [ { provide: PackageService, useValue: service }, - { provide: MatchingService, useValue: matchingService }, + { provide: RecipientService, useValue: recipientService }, + { provide: PackageRequestService, useValue: requestService }, + { provide: TrackingService, useValue: trackingService }, ], }) .overrideGuard(AccessTokenGuard) @@ -34,4 +42,36 @@ describe('PackageController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should delegate recipient routes to RecipientService', async () => { + await controller.createPackageRecipient({ fullName: 'A' } as any, 'user-1'); + expect(recipientService.createRecipient).toHaveBeenCalledWith('user-1', { + fullName: 'A', + }); + }); + + it('should delegate request routes to PackageRequestService', async () => { + const session = { packages: [] } as any; + await controller.createTripRequest( + { packageId: 'p', tripId: 't' } as any, + 'user-1', + session, + ); + expect(requestService.createRequest).toHaveBeenCalledWith( + 'user-1', + { packageId: 'p', tripId: 't' }, + session, + ); + + await controller.updateTripRequest('request-1', session); + expect(requestService.updateRequest).toHaveBeenCalledWith( + 'request-1', + session, + ); + }); + + it('should delegate tracking route to TrackingService', async () => { + await controller.getTripTrackingByCode('TRK123'); + expect(trackingService.getTrackingByCode).toHaveBeenCalledWith('TRK123'); + }); }); diff --git a/src/modules/package/package.controller.ts b/src/modules/package/package.controller.ts index 8993feb..35ce759 100644 --- a/src/modules/package/package.controller.ts +++ b/src/modules/package/package.controller.ts @@ -14,7 +14,10 @@ import { Session, UseGuards, } from '@nestjs/common'; -import { PackageService } from './package.service'; +import { PackageService } from './application/package.service'; +import { RecipientService } from './domain/recipient.service'; +import { PackageRequestService } from './domain/package-request.service'; +import { TrackingService } from './domain/tracking.service'; import { CreateRecipientDto } from './dto/create-recipient.dto'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { @@ -55,7 +58,12 @@ import { TrackingResponseDto } from './dto/tracking-response.dto'; @Controller('packages') export class PackageController { - constructor(private packageService: PackageService) {} + constructor( + private packageService: PackageService, + private recipientService: RecipientService, + private requestService: PackageRequestService, + private trackingService: TrackingService, + ) {} @ApiOperation({ summary: 'Create a new recipient', @@ -77,7 +85,7 @@ export class PackageController { @Body() body: CreateRecipientDto, @CurrentUser('id') id: string, ) { - return this.packageService.createRecipient(id, body); + return this.recipientService.createRecipient(id, body); } @ApiOperation({ @@ -95,7 +103,7 @@ export class PackageController { @CurrentUser('id') id: string, @Query('search') search?: string, ) { - return this.packageService.getAllRecipients(id, search); + return this.recipientService.getAllRecipients(id, search); } @ApiOperation({ @@ -258,7 +266,7 @@ export class PackageController { @CurrentUser('id') userId: string, @Session() session: SessionData, ) { - return this.packageService.createRequest(userId, body, session); + return this.requestService.createRequest(userId, body, session); } @ApiOperation({ @@ -282,7 +290,7 @@ export class PackageController { @Query('status', new ParseEnumPipe(RequestStatusEnum, { optional: true })) status?: RequestStatusEnum, ) { - return this.packageService.getAllPackageRequests( + return this.requestService.getAllPackageRequests( id, status ? [status] : undefined, ); @@ -306,7 +314,7 @@ export class PackageController { @Param('id', ParseUUIDPipe) id: string, @Session() session: SessionData, ) { - return this.packageService.updateRequest(id, session); + return this.requestService.updateRequest(id, session); } @ApiOperation({ @@ -319,6 +327,6 @@ export class PackageController { @Serialize(TrackingResponseDto) @Get('tracking/:code') async getTripTrackingByCode(@Param('code') code: string) { - return this.packageService.getTrackingByCode(code); + return this.trackingService.getTrackingByCode(code); } } diff --git a/src/modules/package/package.module.ts b/src/modules/package/package.module.ts index c1ccb26..9dbffd9 100644 --- a/src/modules/package/package.module.ts +++ b/src/modules/package/package.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common'; -import { PackageService } from './package.service'; +import { PackageService } from './application/package.service'; +import { RecipientService } from './domain/recipient.service'; +import { PackageRequestService } from './domain/package-request.service'; +import { TrackingService } from './domain/tracking.service'; import { PackageController } from './package.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { TokenModule } from '../token/token.module'; @@ -24,7 +27,13 @@ import { NotificationModule } from '../notification/notification.module'; TurfModule, NotificationModule, ], - providers: [PackageService, MatchingService], + providers: [ + PackageService, + MatchingService, + RecipientService, + PackageRequestService, + TrackingService, + ], controllers: [PackageController], }) export class PackageModule { diff --git a/src/modules/package/package.service.spec.ts b/src/modules/package/package.service.spec.ts deleted file mode 100644 index 9c86a59..0000000 --- a/src/modules/package/package.service.spec.ts +++ /dev/null @@ -1,939 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PackageService } from './package.service'; -import { MatchingService } from './matching.service'; -import { TripService } from '../trip/trip.service'; -import { S3Service } from '../s3/s3.service'; -import { TurfService } from '../turf/turf.service'; -import { PricingService } from '../pricing/pricing.service'; -import { MapService } from '../map/map.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { - PrismaClient, - PackageStatusEnum, - RequestStatusEnum, - TripStatusEnum, -} from '../../../generated/prisma'; -import { - BadRequestException, - ForbiddenException, - NotFoundException, -} from '@nestjs/common'; -import { - AuthMessages, - BadRequestMessages, - NotFoundMessages, -} from '../../common/enums/messages.enum'; -import { CreateRecipientDto } from './dto/create-recipient.dto'; -import { CreatePackageDto } from './dto/create-package.dto'; -import { UpdatePackageDto } from './dto/update.package.dto'; -import { CreateRequestDto } from '../trip/dto/create-request.dto'; -import * as utilities from '../../common/utilities'; -import { NotificationService } from '../notification/notification.service'; - -jest.mock('../../common/utilities', () => ({ - formatPrismaError: jest.fn(), -})); - -describe('PackageService', () => { - let service: PackageService; - let prismaService: DeepMockProxy; - let mapService: DeepMockProxy; - let pricingService: DeepMockProxy; - let matchingService: DeepMockProxy; - let tripService: DeepMockProxy; - let s3Service: DeepMockProxy; - let turfService: DeepMockProxy; - let notificationService: DeepMockProxy; - - const mockCity = { - id: 'city-123', - name: 'تهران', - province: { - id: 'province-123', - name: 'تهران', - }, - } as any; - - const mockOriginAddress = { - id: 'address-123', - userId: 'user-123', - title: 'خانه', - latitude: 35.6892, - longitude: 51.389, - city: 'تهران', - province: 'تهران', - } as any; - - const mockRecipient = { - id: 'recipient-123', - fullName: 'علی احمدی', - phoneNumber: '+989123456788', - address: { - userId: 'user-123', - title: 'دفتر کار', - city: 'تهران', - province: 'تهران', - street: 'خیابان ولیعصر', - details: 'روبروی ساختمان قدیم', - latitude: '35.7219', - longitude: '51.3347', - postalCode: '1234567890', - }, - } as any; - - const mockPackage = { - id: 'package-123', - senderId: 'user-123', - items: ['کتاب', 'لپ‌تاپ'], - weight: 2000, - dimensions: '30x20x15', - status: PackageStatusEnum.created, - suggestedPrice: 50000, - finalPrice: 50000, - isFragile: false, - isPerishable: false, - pickupAtOrigin: true, - deliveryAtDestination: true, - picturesKey: ['pic1.jpg', 'pic2.jpg'], - originAddress: mockOriginAddress, - recipient: mockRecipient, - deletedAt: null, - } as any; - - const mockTrip = { - id: 'trip-123', - status: TripStatusEnum.scheduled, - origin: { latitude: 35.6892, longitude: 51.389 }, - destination: { latitude: 35.7219, longitude: 51.3347 }, - waypoints: [], - normalDistanceKm: 10, - normalDurationMin: 30, - totalDeviationDurationMin: 0, - matchedRequests: [], - } as any; - - const mockBreakdown = { - basePrice: 20000, - distanceCost: 15000, - weightCost: 10000, - specialHandlingCost: 0, - cityPremiumCost: 5000, - }; - - const mockMatchedRequest = { - id: 'matched-123', - tripId: 'trip-123', - packageId: 'package-123', - trackingCode: '17571445988911932924', - deliveryCode: '12345', - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - package: { - id: 'package-123', - status: PackageStatusEnum.matched, - senderId: 'user-123', - originAddress: { - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - }, - recipient: { - address: { - latitude: '35.7219', - longitude: '51.3347', - city: 'Tehran', - }, - }, - breakdown: { baseCost: 100000, deviationCost: 0 }, - }, - trip: { - status: TripStatusEnum.in_progress, - transporter: { - rate: 4.5, - rateCount: 10, - profilePictureKey: 'profile-pic-key', - user: { - firstName: 'Ahmad', - lastName: 'Mohammadi', - phoneNumber: '+989123456789', - }, - }, - vehicle: { - vehicleType: 'truck', - model: { brand: { name: 'Volvo' } }, - }, - }, - } as any; - - beforeEach(async () => { - jest.resetAllMocks(); - - prismaService = mockDeep(); - mapService = mockDeep(); - pricingService = mockDeep(); - matchingService = mockDeep(); - tripService = mockDeep(); - s3Service = mockDeep(); - turfService = mockDeep(); - notificationService = mockDeep(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PackageService, - { provide: PrismaService, useValue: prismaService }, - { provide: MapService, useValue: mapService }, - { provide: PricingService, useValue: pricingService }, - { provide: MatchingService, useValue: matchingService }, - { provide: TripService, useValue: tripService }, - { provide: S3Service, useValue: s3Service }, - { provide: TurfService, useValue: turfService }, - { provide: NotificationService, useValue: notificationService }, - ], - }).compile(); - - service = module.get(PackageService); - }); - - describe('createRecipient', () => { - const recipientDto: CreateRecipientDto = { - fullName: 'علی احمدی', - phoneNumber: '+989123456788', - address: { - cityId: 'city-123', - title: 'دفتر کار', - street: 'خیابان ولیعصر', - details: 'روبروی ساختمان قدیم', - latitude: '35.7219', - longitude: '51.3347', - postalCode: '1234567890', - }, - isHighlighted: true, - }; - - it('should create recipient successfully', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.city.findUniqueOrThrow.mockResolvedValue(mockCity); - prismaService.packageRecipient.create.mockResolvedValue(mockRecipient); - - const result = await service.createRecipient('user-123', recipientDto); - - expect(result).toEqual(mockRecipient); - expect(prismaService.city.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { id: 'city-123' }, - include: { province: true }, - }); - expect(prismaService.packageRecipient.create).toHaveBeenCalledWith({ - data: { - fullName: 'علی احمدی', - isHighlighted: true, - phoneNumber: '+989123456788', - address: { - create: { - userId: 'user-123', - title: 'دفتر کار', - city: 'تهران', - province: 'تهران', - street: 'خیابان ولیعصر', - details: 'روبروی ساختمان قدیم', - latitude: '35.7219', - longitude: '51.3347', - postalCode: '1234567890', - }, - }, - }, - include: { address: true }, - }); - }); - - it('should handle Prisma errors', async () => { - const error = new Error('DB error'); - prismaService.$transaction.mockRejectedValue(error); - (utilities.formatPrismaError as unknown as jest.Mock).mockImplementation( - () => { - throw new Error('Formatted error'); - }, - ); - - await expect( - service.createRecipient('user-123', recipientDto), - ).rejects.toThrow('Formatted error'); - }); - }); - - describe('getAllRecipients', () => { - it('should return highlighted recipients', async () => { - const recipients = [mockRecipient]; - prismaService.packageRecipient.findMany.mockResolvedValue(recipients); - - const result = await service.getAllRecipients('user-123'); - - expect(result).toEqual(recipients); - expect(prismaService.packageRecipient.findMany).toHaveBeenCalledWith({ - where: { - address: { userId: 'user-123' }, - isHighlighted: true, - OR: [ - { - fullName: { - contains: undefined, - mode: 'insensitive', - }, - }, - { - address: { - title: { - contains: undefined, - mode: 'insensitive', - }, - }, - }, - ], - }, - include: { address: true }, - }); - }); - - it('should filter by search term', async () => { - const recipients = [mockRecipient]; - prismaService.packageRecipient.findMany.mockResolvedValue(recipients); - - await service.getAllRecipients('user-123', 'علی'); - - expect(prismaService.packageRecipient.findMany).toHaveBeenCalledWith({ - where: { - address: { userId: 'user-123' }, - isHighlighted: true, - OR: [ - { - fullName: { - contains: 'علی', - mode: 'insensitive', - }, - }, - { - address: { - title: { - contains: 'علی', - mode: 'insensitive', - }, - }, - }, - ], - }, - include: { address: true }, - }); - }); - }); - - describe('create', () => { - const packageDto: CreatePackageDto = { - items: ['کتاب'], - originAddressId: 'address-123', - recipientId: 'recipient-123', - weight: 1500, - dimensions: '25x20x10', - isFragile: false, - isPerishable: false, - pickupAtOrigin: true, - deliveryAtDestination: true, - picturesKey: ['pic1.jpg'], - } as any; - - it('should create package successfully', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.address.findFirst.mockResolvedValue(mockOriginAddress); - prismaService.packageRecipient.findFirst.mockResolvedValue(mockRecipient); - prismaService.package.create.mockResolvedValue(mockPackage); - - mapService.calculateDistance.mockResolvedValue({ - distance: 15, - duration: 25, - }); - pricingService.calculateSuggestedPrice.mockReturnValue({ - suggestedPrice: 50000, - breakdown: mockBreakdown, - }); - - const result = await service.create('user-123', packageDto); - - expect(result).toEqual(mockPackage); - expect(mapService.calculateDistance).toHaveBeenCalledWith({ - origin: { - latitude: mockOriginAddress.latitude, - longitude: mockOriginAddress.longitude, - }, - destination: { - latitude: mockRecipient.address.latitude, - longitude: mockRecipient.address.longitude, - }, - }); - expect(pricingService.calculateSuggestedPrice).toHaveBeenCalled(); - }); - - it('should throw when origin address not found', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.address.findFirst.mockResolvedValue(null); - - await expect(service.create('user-123', packageDto)).rejects.toThrow( - new ForbiddenException( - `${AuthMessages.EntityAccessDenied} origin address.`, - ), - ); - }); - - it('should throw when recipient not found', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.address.findFirst.mockResolvedValue(mockOriginAddress); - prismaService.packageRecipient.findFirst.mockResolvedValue(null); - - await expect(service.create('user-123', packageDto)).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} recipient.`), - ); - }); - }); - - describe('getById', () => { - it('should return package with presigned URLs', async () => { - const packageWithUrls = { - ...mockPackage, - picturesKey: ['pic1.jpg', 'pic2.jpg'], - picturesUrl: ['url1', 'url2'], - }; - - prismaService.package.findFirstOrThrow.mockResolvedValue(mockPackage); - s3Service.generateGetPresignedUrl - .mockResolvedValueOnce('url1') - .mockResolvedValueOnce('url2'); - - const result = await service.getById('package-123'); - - expect(result).toMatchObject(packageWithUrls); - }); - // TODO: fix - // it('should handle S3 URL generation errors', async () => { - // prismaService.package.findFirstOrThrow.mockResolvedValue(mockPackage); - - // s3Service.generateGetPresignedUrl.mockReset(); - // s3Service.generateGetPresignedUrl - // .mockRejectedValue(new Error('S3 error')) - // .mockResolvedValueOnce('url2'); - - // const result = await service.getById('package-123'); - - // expect(result.picturesKey).toEqual({ - // keys: ['pic1.jpg', 'pic2.jpg'], - // presignedUrls: ['', 'url2'] - // }); - // }); - }); - - describe('update', () => { - const updateDto: UpdatePackageDto = { - dimensions: '35x25x20', - finalPrice: 60000, - }; - - it('should update package successfully', async () => { - const updatedPackage = { ...mockPackage, ...updateDto }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.created, - suggestedPrice: 50000, - } as any); - prismaService.package.update.mockResolvedValue(updatedPackage); - - const result = await service.update('package-123', updateDto); - - expect(result).toEqual(updatedPackage); - expect(prismaService.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123' }, - data: updateDto, - }); - }); - - it('should throw when package status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.matched, - suggestedPrice: 50000, - } as any); - - await expect(service.update('package-123', updateDto)).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, - ), - ); - }); - - it('should throw when final price is below suggested price', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.created, - suggestedPrice: 50000, - } as any); - - await expect( - service.update('package-123', { finalPrice: 40000 }), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.InvalidPrice), - ); - }); - }); - - describe('delete', () => { - it('should soft delete package successfully', async () => { - const deletedPackage = { ...mockPackage, deletedAt: new Date() }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.created, - } as any); - prismaService.package.update.mockResolvedValue(deletedPackage); - - const result = await service.delete('package-123'); - - expect(result.deletedAt).toBeDefined(); - expect(prismaService.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123' }, - data: { deletedAt: expect.any(Date) }, - }); - }); - - it('should throw when package status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - - await expect(service.delete('package-123')).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, - ), - ); - }); - }); - - describe('getMatchedTrips', () => { - const session = { - packages: [], - } as any; - - it('should return matched trips with deviation info', async () => { - const matchResults = [ - { - tripId: 'trip-123', - isRequestSent: false, - score: 100, - originDistance: 500, - destinationDistance: 300, - isOnCorridor: true, - }, - ]; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - service.getById = jest.fn().mockResolvedValue(mockPackage); - matchingService.findMatchedTrips.mockResolvedValue(matchResults); - tripService.getMultipleById.mockResolvedValue([mockTrip]); - turfService.sortLocationsByRoute.mockReturnValue([] as any); - mapService.calculateDistance.mockResolvedValue({ - distance: 12, - duration: 35, - }); - pricingService.calculateDeviationCost.mockReturnValue(5000); - - const result = await service.getMatchedTrips('package-123', session); - - expect(result).toHaveLength(1); - expect(result[0]?.additionalPrice).toBe(5000); - }); - - it('should throw when package status is invalid', async () => { - const invalidPackage = { - ...mockPackage, - status: PackageStatusEnum.matched, - }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - service.getById = jest.fn().mockResolvedValue(invalidPackage); - - await expect( - service.getMatchedTrips('package-123', session), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.SendRequestPackage), - ); - }); - }); - - describe('createRequest', () => { - const requestDto: CreateRequestDto = { - packageId: 'package-123', - tripId: 'trip-123', - senderNote: 'لطفا با دقت حمل کنید', - }; - - const session = { - packages: [ - { - id: 'package-123', - matchResults: [ - { - tripId: 'trip-123', - isRequestSent: false, - score: 100, - originDistance: 500, - destinationDistance: 300, - isOnCorridor: true, - deviationInfo: { - distance: 2, - duration: 5, - additionalPrice: 3000, - }, - }, - ], - }, - ], - } as any; - - it('should create request successfully', async () => { - const createdRequest = { - id: 'request-123', - packageId: 'package-123', - tripId: 'trip-123', - status: RequestStatusEnum.pending, - offeredPrice: 45000, - deviationCost: 3000, - }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.searching_transporter, - }); - prismaService.trip.findUniqueOrThrow.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.scheduled, - }); - prismaService.tripRequest.create.mockResolvedValue(createdRequest as any); - pricingService.calculateTransporterEarnings.mockReturnValue(42000); - - const result = await service.createRequest( - 'user-123', - requestDto, - session, - ); - - expect(result).toEqual(createdRequest); - expect(session.packages[0].matchResults[0].isRequestSent).toBe(true); - }); - - it('should throw when user does not own package', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - senderId: 'other-user', - }); - - await expect( - service.createRequest('user-123', requestDto, session), - ).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), - ); - }); - - it('should throw when package status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.matched, - }); - - await expect( - service.createRequest('user-123', requestDto, session), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.SendRequestPackage), - ); - }); - - it('should throw when trip status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.searching_transporter, - }); - prismaService.trip.findUniqueOrThrow.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.completed, - }); - - await expect( - service.createRequest('user-123', requestDto, session), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.SendRequestTrip), - ); - }); - - it('should throw when no matched trips found', async () => { - const emptySession = { - packages: [], - } as any; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.searching_transporter, - }); - prismaService.trip.findUniqueOrThrow.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.scheduled, - }); - - await expect( - service.createRequest('user-123', requestDto, emptySession), - ).rejects.toThrow(new NotFoundException(NotFoundMessages.MatchedTrip)); - }); - }); - - describe('getAllPackageRequests', () => { - it('should return all package requests', async () => { - const requests = [ - { - id: 'request-1', - packageId: 'package-123', - status: RequestStatusEnum.pending, - }, - { - id: 'request-2', - packageId: 'package-123', - status: RequestStatusEnum.accepted, - }, - ]; - - prismaService.tripRequest.findMany.mockResolvedValue(requests as any); - - const result = await service.getAllPackageRequests('package-123'); - - expect(result).toEqual(requests); - expect(prismaService.tripRequest.findMany).toHaveBeenCalledWith({ - where: { - packageId: 'package-123', - status: { - in: Object.values(RequestStatusEnum), - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - }); - - it('should filter by status', async () => { - const pendingRequests = [ - { - id: 'request-1', - packageId: 'package-123', - status: RequestStatusEnum.pending, - }, - ]; - - prismaService.tripRequest.findMany.mockResolvedValue( - pendingRequests as any, - ); - - await service.getAllPackageRequests('package-123', [ - RequestStatusEnum.pending, - ]); - - expect(prismaService.tripRequest.findMany).toHaveBeenCalledWith({ - where: { - packageId: 'package-123', - status: { - in: [RequestStatusEnum.pending], - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - }); - }); - - describe('updateRequest', () => { - it('should cancel request and update session', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - const canceledRequest = { - id: 'request-123', - packageId: 'package-123', - tripId: 'trip-123', - status: RequestStatusEnum.canceled, - }; - - const session = { - packages: [ - { - id: 'package-123', - matchResults: [ - { - tripId: 'trip-123', - isRequestSent: true, - score: 100, - originDistance: 500, - destinationDistance: 300, - isOnCorridor: true, - }, - ], - }, - ], - } as any; - - prismaService.tripRequest.update.mockResolvedValue({ - ...canceledRequest, - package: { id: 'package-123', senderId: 'user-123' }, - } as any); - - const result = await service.updateRequest('request-123', session); - - expect(result).toEqual(canceledRequest); - expect(session.packages[0].matchResults[0].isRequestSent).toBe(false); - expect(prismaService.tripRequest.update).toHaveBeenCalledWith({ - where: { - id: 'request-123', - status: RequestStatusEnum.pending, - }, - data: { - status: RequestStatusEnum.canceled, - }, - include: { - package: { - select: { - id: true, - senderId: true, - }, - }, - }, - }); - }); - - it('should handle missing session data gracefully', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - const canceledRequest = { - id: 'request-123', - packageId: 'package-456', - tripId: 'trip-789', - status: RequestStatusEnum.canceled, - }; - - const session = { - packages: [], - } as any; - - prismaService.tripRequest.update.mockResolvedValue({ - ...canceledRequest, - package: { id: 'package-456', senderId: 'user-456' }, - } as any); - - const result = await service.updateRequest('request-123', session); - - expect(result).toEqual(canceledRequest); - }); - }); - - describe('getTripTrackingByCode', () => { - it('should get trip tracking by code successfully', async () => { - const matchedRequestWithTracking = { - ...mockMatchedRequest, - trackingUpdates: [{ city: 'Tehran', createdAt: new Date() }], - }; - - prismaService.matchedRequest.findUniqueOrThrow.mockResolvedValue( - matchedRequestWithTracking, - ); - s3Service.generateGetPresignedUrl.mockResolvedValue( - 'https://s3.example.com/profile.jpg', - ); - - const result = await service.getTrackingByCode('TRK123456789'); - - expect(result.trackingUpdates).toBeDefined(); - expect(result.package).toBeDefined(); - expect(result.transporter).toBeDefined(); - expect(result.transporter.profilePictureUrl).toBe( - 'https://s3.example.com/profile.jpg', - ); - }); - }); - - describe('Error scenarios', () => { - it('should handle Prisma errors in getAll', async () => { - const error = new Error('DB error'); - prismaService.package.findMany.mockRejectedValue(error); - (utilities.formatPrismaError as unknown as jest.Mock).mockImplementation( - () => { - throw new Error('Formatted error'); - }, - ); - - await expect(service.getAll('user-123')).rejects.toThrow( - 'Formatted error', - ); - }); - - it('should handle empty picturesKey array', async () => { - const packageWithoutPics = { ...mockPackage, picturesKey: [] }; - prismaService.package.findMany.mockResolvedValue([packageWithoutPics]); - - const result = await service.getAll('user-123'); - - expect(result).toBeDefined(); - }); - - it('should handle null picturesKey', async () => { - const packageWithNullPics = { ...mockPackage, picturesKey: null }; - prismaService.package.findFirstOrThrow.mockResolvedValue( - packageWithNullPics, - ); - - const result = await service.getById('package-123'); - - expect(result.picturesKey).toBeNull(); - }); - }); -}); diff --git a/src/modules/package/package.service.ts b/src/modules/package/package.service.ts deleted file mode 100644 index 1d6e80e..0000000 --- a/src/modules/package/package.service.ts +++ /dev/null @@ -1,846 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { CreateRecipientDto } from './dto/create-recipient.dto'; -import { CreatePackageDto } from './dto/create-package.dto'; -import { - AuthMessages, - BadRequestMessages, - NotFoundMessages, -} from '../../common/enums/messages.enum'; -import { formatPrismaError } from '../../common/utilities'; -import { UpdatePackageDto } from './dto/update.package.dto'; -import { - PackageStatusEnum, - RequestStatusEnum, - TripStatusEnum, -} from '../../../generated/prisma'; -import { MapService } from '../map/map.service'; -import { PricingService } from '../pricing/pricing.service'; -import { S3Service } from '../s3/s3.service'; -import { SessionData } from 'express-session'; -import { MatchingService } from './matching.service'; -import { TripService } from '../trip/trip.service'; -import { PrismaTransaction } from '../prisma/prisma.types'; -import { JsonArray } from '../../../generated/prisma/runtime/library'; -import { CreateRequestDto } from '../trip/dto/create-request.dto'; -import { instanceToPlain } from 'class-transformer'; -import { TurfService } from '../turf/turf.service'; -import { NotificationService } from '../notification/notification.service'; -import { - getNotificationMessage, - NotificationMessages, -} from '../notification/notification-messages'; - -@Injectable() -export class PackageService { - constructor( - private prisma: PrismaService, - private mapService: MapService, - private pricingService: PricingService, - private matchingService: MatchingService, - private tripService: TripService, - private s3Service: S3Service, - private turfService: TurfService, - private notificationService: NotificationService, - ) {} - - async createRecipient( - userId: string, - { address: { cityId, ...address }, ...recipientDto }: CreateRecipientDto, - ) { - return this.prisma - .$transaction(async (tx) => { - const city = await tx.city.findUniqueOrThrow({ - where: { id: cityId }, - include: { - province: true, - }, - }); - - return tx.packageRecipient.create({ - data: { - ...recipientDto, - address: { - create: { - userId, - ...address, - title: address.title ?? recipientDto.fullName, - province: city.province.name, - city: city.name, - }, - }, - }, - include: { - address: true, - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllRecipients( - userId: string, - search?: string, - isHighlighted = true, - ) { - return this.prisma.packageRecipient - .findMany({ - where: { - address: { - userId, - }, - isHighlighted, - OR: [ - { - fullName: { - contains: search, - mode: 'insensitive', - }, - }, - { - address: { - title: { - contains: search, - mode: 'insensitive', - }, - }, - }, - ], - }, - include: { - address: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async create( - userId: string, - { items, originAddressId, recipientId, ...packageDto }: CreatePackageDto, - ) { - return this.prisma - .$transaction(async (tx) => { - const originAddress = await tx.address.findFirst({ - where: { - id: originAddressId, - userId, - }, - }); - - if (!originAddress) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} origin address.`, - ); - } - - const recipient = await tx.packageRecipient.findFirst({ - where: { - id: recipientId, - address: { - userId, - }, - }, - include: { - address: true, - }, - }); - - if (!recipient) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} recipient.`, - ); - } - - // Calculate distance for pricing - const { distance } = await this.mapService.calculateDistance({ - origin: { - latitude: originAddress.latitude, - longitude: originAddress.longitude, - }, - destination: { - latitude: recipient.address.latitude, - longitude: recipient.address.longitude, - }, - }); - - const { suggestedPrice, breakdown } = - this.pricingService.calculateSuggestedPrice({ - distanceKm: distance, - weightGr: packageDto.weight, - isFragile: packageDto.isFragile ?? false, - isPerishable: packageDto.isPerishable ?? false, - originCity: originAddress.city!, - destinationCity: recipient.address.city!, - }); - - const plainBreakdown = instanceToPlain(breakdown); - - const packageData = await tx.package.create({ - data: { - senderId: userId, - items, - originAddressId: originAddress.id, - recipientId: recipient.id, - ...packageDto, - suggestedPrice, - breakdown: plainBreakdown, - finalPrice: suggestedPrice, - }, - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - }, - }); - - // Add create package notification - await this.notificationService.create( - userId, - { - packageId: packageData.id, - content: getNotificationMessage( - NotificationMessages.PackageCreated, - { packageCode: packageData.code }, - ), - }, - tx, - ); - - return packageData; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getById(id: string, tx: PrismaTransaction = this.prisma) { - const packageData = await tx.package - .findFirstOrThrow({ - where: { - id, - deletedAt: null, - }, - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - matchedRequest: { - select: { - trip: { - select: { - transporter: { - select: { - rate: true, - user: { - select: { - firstName: true, - lastName: true, - gender: true, - phoneNumber: true, - }, - }, - }, - }, - vehicle: { - select: { - vehicleType: true, - model: { - select: { - brand: true, - }, - }, - manufactureYear: true, - color: true, - }, - }, - departureTime: true, - status: true, - description: true, - }, - }, - trackingCode: true, - deliveryCode: true, - transporterNotes: true, - senderRating: true, - senderComment: true, - isCompleted: true, - pickupTime: true, - deliveryTime: true, - paymentStatus: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const keys = packageData.picturesKey; - let picturesUrl: string[] | undefined; - if (packageData.picturesKey && Array.isArray(packageData.picturesKey)) { - picturesUrl = await this.generatePackagePicPresignedUrl(keys as any); - } - - return { - ...packageData, - picturesUrl, - }; - } - - async getAll( - userId: string, - status: PackageStatusEnum[] = [ - PackageStatusEnum.created, - PackageStatusEnum.searching_transporter, - PackageStatusEnum.matched, - PackageStatusEnum.picked_up, - PackageStatusEnum.in_transit, - ], - page = 1, - limit = 10, - ) { - const skip = (page - 1) * limit; - const packages = await this.prisma.package - .findMany({ - where: { - senderId: userId, - status: { - in: status, - }, - deletedAt: null, - }, - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - skip, - take: limit, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - return Promise.all( - packages.map(async (packageData) => { - const keys = packageData.picturesKey; - if (keys && Array.isArray(keys)) { - return { - ...packageData, - picturesUrl: await this.generatePackagePicPresignedUrl(keys), - picturesKey: undefined, - }; - } - }), - ); - } - - async update(id: string, packageDto: UpdatePackageDto) { - return this.prisma - .$transaction(async (tx) => { - const { status, suggestedPrice } = await tx.package.findFirstOrThrow({ - where: { - id, - deletedAt: null, - }, - select: { - status: true, - suggestedPrice: true, - }, - }); - - const isValidStatus = - status === PackageStatusEnum.created || - status === PackageStatusEnum.searching_transporter; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${status}.`, - ); - } - - if (packageDto.finalPrice < suggestedPrice) { - throw new BadRequestException(BadRequestMessages.InvalidPrice); - } - - return this.prisma.package.update({ - where: { id }, - data: packageDto, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async generatePackagePicPresignedUrl(keys: JsonArray) { - return Promise.all( - keys.map(async (key, index) => { - const keyString = JSON.stringify(key).split('"')[1]; - try { - if (keyString) { - return this.s3Service.generateGetPresignedUrl(keyString); - } - return ''; - } catch (urlError) { - console.error( - `Failed to generate presigned URL for picturesKey[${index}]:`, - urlError, - ); - return ''; - } - }), - ); - } - - private async updateStatus( - id: string, - status: PackageStatusEnum, - tx: PrismaTransaction = this.prisma, - ) { - return tx.package.update({ - where: { - id, - deletedAt: null, - }, - data: { - status, - }, - }); - } - - async delete(id: string) { - return this.prisma - .$transaction(async (tx) => { - const { status } = await tx.package.findFirstOrThrow({ - where: { id }, - select: { status: true }, - }); - - const isValidStatus = - status === PackageStatusEnum.created || - status === PackageStatusEnum.searching_transporter; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${status}.`, - ); - } - - return this.prisma.package.update({ - where: { id }, - data: { - deletedAt: new Date(), - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getMatchedTrips( - packageId: string, - session: SessionData, - maxResults = 20, - ) { - return this.prisma.$transaction(async (tx) => { - const packageData = await this.getById(packageId, tx); - - const idValidPackageStatus = - packageData.status === PackageStatusEnum.created || - packageData.status === PackageStatusEnum.searching_transporter || - packageData.status === PackageStatusEnum.cancelled; - if (!idValidPackageStatus) { - throw new BadRequestException(BadRequestMessages.SendRequestPackage); - } - - // Update package status - await this.updateStatus( - packageId, - PackageStatusEnum.searching_transporter, - tx, - ); - - // Do matching - const matchedTrips = await this.matchingService.findMatchedTrips( - packageData, - session, - maxResults, - tx, - ); - - // Fetch trips - const tripIds = matchedTrips - .filter((m) => !m.isRequestSent) - .slice(0, maxResults) - .map((m) => m.tripId); - const trips = await this.tripService.getMultipleById(tripIds, tx); - const tripMap = new Map(trips.map((trip) => [trip.id, trip])); - - // Calculate deviation info - const matchingResult = await Promise.all( - matchedTrips.map(async (matchedTrip) => { - const trip = tripMap.get(matchedTrip.tripId); - if (!trip) return; - - const waypoints = trip.matchedRequests - .flatMap((r) => [ - r.package.pickupAtOrigin - ? { - latitude: r.package.originAddress.latitude, - longitude: r.package.originAddress.longitude, - } - : undefined, - r.package.deliveryAtDestination - ? { - latitude: r.package.recipient.address.latitude, - longitude: r.package.recipient.address.longitude, - } - : undefined, - ]) - .filter((v) => v !== undefined); - - waypoints.push( - ...[ - packageData.pickupAtOrigin - ? { - latitude: packageData.originAddress.latitude, - longitude: packageData.originAddress.longitude, - } - : undefined, - packageData.deliveryAtDestination - ? { - latitude: packageData.recipient.address.latitude, - longitude: packageData.recipient.address.longitude, - } - : undefined, - ].filter((v) => v !== undefined), - ); - - const sortedWaypoints = this.turfService.sortLocationsByRoute( - trip.origin, - trip.destination, - waypoints, - ); - - const { distance, duration } = - await this.mapService.calculateDistance({ - origin: { - latitude: trip.origin.latitude, - longitude: trip.origin.longitude, - }, - destination: { - latitude: trip.destination.latitude, - longitude: trip.destination.longitude, - }, - waypoints: sortedWaypoints, - }); - - const deviationDistance = Math.max( - 0, - distance - - ((trip.normalDistanceKm ?? 0) + - (trip.totalDeviationDurationMin ?? 0)), - ); - const deviationDuration = Math.max( - 0, - duration - - ((trip.normalDurationMin ?? 0) + - (trip.totalDeviationDurationMin ?? 0)), - ); - - const additionalPrice = this.pricingService.calculateDeviationCost( - deviationDistance, - deviationDuration, - ); - matchedTrip.deviationInfo = { - distance: deviationDistance, - duration: deviationDuration, - additionalPrice, - }; - - return { - ...trip, - additionalPrice, - }; - }), - ); - - return matchingResult.filter(Boolean); - }); - } - - async createRequest( - userId: string, - { packageId, tripId, senderNote }: CreateRequestDto, - session: SessionData, - ) { - return this.prisma - .$transaction(async (tx) => { - const packageData = await tx.package.findUniqueOrThrow({ - where: { id: packageId }, - }); - - if (userId !== packageData.senderId) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} package.`, - ); - } - - if (packageData.status !== PackageStatusEnum.searching_transporter) { - throw new BadRequestException(BadRequestMessages.SendRequestPackage); - } - - const tripData = await tx.trip.findUniqueOrThrow({ - where: { id: tripId }, - include: { - origin: true, - destination: true, - waypoints: true, - }, - }); - - if (tripData.status !== TripStatusEnum.scheduled) { - throw new BadRequestException(BadRequestMessages.SendRequestTrip); - } - - const matchedTrips = session.packages.find( - (p) => p.id === packageId, - )?.matchResults; - if (!matchedTrips || !matchedTrips.length) { - throw new NotFoundException(NotFoundMessages.MatchedTrip); - } - - const matchedTrip = matchedTrips.find((t) => t.tripId === tripId); - if (!matchedTrip) { - throw new BadRequestException(BadRequestMessages.SendRequestTrip); - } - - const deviationDistance = matchedTrip.deviationInfo?.distance ?? 0; - const deviationDuration = matchedTrip.deviationInfo?.duration ?? 0; - const deviationCost = matchedTrip.deviationInfo?.additionalPrice ?? 0; - const transporterEarnings = - this.pricingService.calculateTransporterEarnings( - packageData.finalPrice, - deviationCost, - ); - const offeredPrice = transporterEarnings + deviationCost; - - const request = await tx.tripRequest.create({ - data: { - packageId, - tripId, - deviationDistanceKm: deviationDistance, - deviationDurationMin: deviationDuration, - deviationCost, - offeredPrice, - senderNote, - }, - }); - - // Update session - matchedTrip.isRequestSent = true; - - // Add create request notification - await this.notificationService.create( - userId, - { - packageId, - content: getNotificationMessage( - NotificationMessages.TripRequestCreated, - { - packageCode: packageData.code, - tripCode: tripData.code, - }, - ), - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllPackageRequests( - packageId: string, - status: RequestStatusEnum[] = Object.values(RequestStatusEnum), - ) { - return this.prisma.tripRequest.findMany({ - where: { - packageId, - status: { - in: status, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - } - - async updateRequest(requestId: string, session: SessionData) { - return this.prisma - .$transaction(async (tx) => { - const { package: packageData, ...request } = - await tx.tripRequest.update({ - where: { - id: requestId, - status: RequestStatusEnum.pending, - }, - data: { - status: RequestStatusEnum.canceled, - }, - include: { - package: { - select: { - id: true, - senderId: true, - }, - }, - }, - }); - - // Update session - const matchedTrip = session.packages - .find((p) => p.id === request.packageId) - ?.matchResults.find((m) => m.tripId === request.tripId); - - if (matchedTrip) matchedTrip.isRequestSent = false; - - // Add create package notification - await this.notificationService.create( - packageData.senderId, - { - packageId: packageData.id, - content: NotificationMessages.TripRequestCanceled, - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getTrackingByCode(trackingCode: string) { - const matchedRequest = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - trackingCode, - deletedAt: null, - }, - include: { - trackingUpdates: { - orderBy: { - createdAt: 'desc', - }, - }, - package: { - select: { - code: true, - sender: { - select: { - firstName: true, - lastName: true, - phoneNumber: true, - }, - }, - recipient: { - include: { - address: true, - }, - }, - items: true, - weight: true, - dimensions: true, - finalPrice: true, - breakdown: true, - status: true, - packageValue: true, - deliveryAtDestination: true, - }, - }, - trip: { - select: { - transporter: { - select: { - profilePictureKey: true, - rate: true, - user: { - select: { - firstName: true, - lastName: true, - phoneNumber: true, - }, - }, - }, - }, - vehicle: { - select: { - vehicleType: true, - model: { - select: { - brand: true, - }, - }, - manufactureYear: true, - color: true, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - return { - trackingUpdates: matchedRequest.trackingUpdates, - package: matchedRequest.package, - transporter: { - profilePictureUrl: await this.s3Service.generateGetPresignedUrl( - matchedRequest.trip.transporter.profilePictureKey, - ), - ...matchedRequest.trip.transporter, - ...matchedRequest.trip.transporter.user, - vehicle: matchedRequest.trip.vehicle, - }, - }; - } -} diff --git a/src/modules/prisma/prisma.module.ts b/src/modules/prisma/prisma.module.ts index 64adbcf..dd47982 100644 --- a/src/modules/prisma/prisma.module.ts +++ b/src/modules/prisma/prisma.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { TransactionRunner } from './transaction-runner'; +import { CityRepository } from './repositories/city.repository'; @Module({ - providers: [PrismaService, TransactionRunner], - exports: [PrismaService, TransactionRunner], + providers: [PrismaService, TransactionRunner, CityRepository], + exports: [PrismaService, TransactionRunner, CityRepository], }) export class PrismaModule {} diff --git a/src/modules/prisma/repositories/city.repository.spec.ts b/src/modules/prisma/repositories/city.repository.spec.ts new file mode 100644 index 0000000..1af5cf1 --- /dev/null +++ b/src/modules/prisma/repositories/city.repository.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CityRepository } from './city.repository'; +import { PrismaService } from '../prisma.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '../../../../generated/prisma'; + +describe('CityRepository', () => { + let repository: CityRepository; + let prisma: DeepMockProxy; + + const mockCity = { id: 'city-123', name: 'Tehran' } as any; + const mockCityWithProvince = { + ...mockCity, + province: { id: 'province-123', name: 'Tehran' }, + }; + + beforeEach(async () => { + prisma = mockDeep(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [CityRepository, { provide: PrismaService, useValue: prisma }], + }).compile(); + + repository = module.get(CityRepository); + }); + + describe('findCityOrThrow', () => { + it('should find a city by id', async () => { + prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); + + const result = await repository.findCityOrThrow('city-123'); + + expect(result).toEqual(mockCity); + expect(prisma.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + }); + }); + + it('should read through the given transaction when provided', async () => { + const tx = mockDeep(); + tx.city.findUniqueOrThrow.mockResolvedValue(mockCity); + + const result = await repository.findCityOrThrow('city-123', tx); + + expect(result).toEqual(mockCity); + expect(tx.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + }); + expect(prisma.city.findUniqueOrThrow).not.toHaveBeenCalled(); + }); + }); + + describe('findCityWithProvince', () => { + it('should find a city with its province', async () => { + prisma.city.findUniqueOrThrow.mockResolvedValue(mockCityWithProvince); + + const result = await repository.findCityWithProvince('city-123'); + + expect(result).toEqual(mockCityWithProvince); + expect(prisma.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + include: { province: true }, + }); + }); + + it('should read through the given transaction when provided', async () => { + const tx = mockDeep(); + tx.city.findUniqueOrThrow.mockResolvedValue(mockCityWithProvince); + + const result = await repository.findCityWithProvince('city-123', tx); + + expect(result).toEqual(mockCityWithProvince); + expect(tx.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + include: { province: true }, + }); + }); + }); +}); diff --git a/src/modules/prisma/repositories/city.repository.ts b/src/modules/prisma/repositories/city.repository.ts new file mode 100644 index 0000000..edf068e --- /dev/null +++ b/src/modules/prisma/repositories/city.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma.service'; +import { PrismaTransaction } from '../prisma.types'; + +/** + * City reads (Phase 4 Task 6, pulled forward from Phase 5). + * + * Single home for `city` lookups so infra-adjacent reads stop spreading + * across `MapService`, trip creation and recipient creation. Callers inside + * an interactive transaction pass their `tx` for snapshot consistency; + * standalone callers (e.g. the map controller path) use the default client. + * No manual `.catch` — the global filter maps Prisma errors. + */ +@Injectable() +export class CityRepository { + constructor(private prisma: PrismaService) {} + + findCityOrThrow(cityId: string, tx: PrismaTransaction = this.prisma) { + return tx.city.findUniqueOrThrow({ + where: { id: cityId }, + }); + } + + findCityWithProvince(cityId: string, tx: PrismaTransaction = this.prisma) { + return tx.city.findUniqueOrThrow({ + where: { id: cityId }, + include: { province: true }, + }); + } +} diff --git a/src/modules/trip/application/trip.service.spec.ts b/src/modules/trip/application/trip.service.spec.ts new file mode 100644 index 0000000..23380a7 --- /dev/null +++ b/src/modules/trip/application/trip.service.spec.ts @@ -0,0 +1,819 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripService } from './trip.service'; +import { TripTrackingService } from '../domain/trip-tracking.service'; +import { FinancialService } from '../../financial/financial.service'; +import { MapService } from '../../map/map.service'; +import { + PrismaClient, + TripStatusEnum, + TripTypeEnum, + PackageStatusEnum, +} from '../../../../generated/prisma'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestMessages, + AuthMessages, + TrackingMessages, +} from '../../../common/enums/messages.enum'; +import { NotificationService } from '../../notification/notification.service'; +import { CityRepository } from '../../prisma/repositories/city.repository'; + +describe('TripService', () => { + let service: TripService; + let prisma: DeepMockProxy; + let mapService: DeepMockProxy; + let financialService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + let trackingService: DeepMockProxy; + let cities: DeepMockProxy; + + const mockVehicle = { + id: 'vehicle-123', + ownerId: 'transporter-123', + owner: { + userId: 'user-123', + }, + } as any; + + const mockCity = { + id: 'city-123', + name: 'Tehran', + latitude: '35.6892', + longitude: '51.3890', + } as any; + + const mockTrip = { + id: 'trip-123', + transporterId: 'transporter-123', + originId: 'city-origin', + destinationId: 'city-dest', + vehicleId: 'vehicle-123', + status: TripStatusEnum.scheduled, + origin: mockCity, + destination: mockCity, + waypoints: [], + vehicle: mockVehicle, + normalDistanceKm: 100, + normalDurationMin: 120, + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + matchedRequests: [], + } as any; + + const mockCreateTripDto = { + originId: 'city-origin', + destinationId: 'city-dest', + vehicleId: 'vehicle-123', + departureTime: [new Date(), new Date(Date.now() + 3600000)] as [Date, Date], + maxPackageWeightGr: 5000, + restrictedItems: ['fragile'], + description: 'Test trip', + }; + + const mockMatchedRequest = { + id: 'matched-123', + tripId: 'trip-123', + packageId: 'package-123', + trackingCode: '17571445988911932924', + deliveryCode: '12345', + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + package: { + id: 'package-123', + status: PackageStatusEnum.matched, + senderId: 'user-123', + originAddress: { + latitude: '35.6892', + longitude: '51.3890', + city: 'Tehran', + }, + recipient: { + address: { + latitude: '35.7219', + longitude: '51.3347', + city: 'Tehran', + }, + }, + breakdown: { baseCost: 100000, deviationCost: 0 }, + }, + trip: { + status: TripStatusEnum.in_progress, + }, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + mapService = mockDeep(); + financialService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + trackingService = mockDeep(); + cities = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TripService, + { provide: PrismaService, useValue: prisma }, + { provide: MapService, useValue: mapService }, + { provide: FinancialService, useValue: financialService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + { provide: TripTrackingService, useValue: trackingService }, + { provide: CityRepository, useValue: cities }, + ], + }).compile(); + + service = module.get(TripService); + }); + + describe('create', () => { + it('should create trip successfully', async () => { + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + const result = await service.create('user-123', mockCreateTripDto); + + expect(result).toEqual(mockTrip); + expect(prisma.vehicle.findFirst).toHaveBeenCalledWith({ + where: { id: 'vehicle-123', owner: { userId: 'user-123' } }, + }); + expect(cities.findCityOrThrow).toHaveBeenCalledWith( + 'city-origin', + prisma, + ); + expect(cities.findCityOrThrow).toHaveBeenCalledWith('city-dest', prisma); + expect(mapService.calculateDistance).toHaveBeenCalled(); + expect(prisma.trip.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + transporterId: 'transporter-123', + originId: 'city-origin', + destinationId: 'city-dest', + vehicleId: 'vehicle-123', + tripType: TripTypeEnum.intercity, + normalDistanceKm: 100, + normalDurationMin: 120, + }), + }); + }); + + it('should create trip with waypoints', async () => { + const tripDtoWithWaypoints = { + ...mockCreateTripDto, + waypoints: [{ id: 'way-1', name: 'Waypoint 1' }], + }; + + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + await service.create('user-123', tripDtoWithWaypoints as any); + + expect(prisma.trip.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + waypoints: { + createMany: { + data: [{ id: 'way-1', name: 'Waypoint 1' }], + }, + }, + }), + }); + }); + + it('should throw when vehicle not found', async () => { + prisma.vehicle.findFirst.mockResolvedValue(null); + + await expect( + service.create('user-123', mockCreateTripDto), + ).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} vehicle.`), + ); + }); + + it('should run creation inside TransactionRunner', async () => { + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + await service.create('user-123', mockCreateTripDto); + + expect(runner.run).toHaveBeenCalledTimes(1); + }); + }); + + describe('getById', () => { + it('should get trip by id successfully', async () => { + const tripWithIncludes = { + ...mockTrip, + waypoints: [{ id: 'way-1', isVisible: true }], + }; + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithIncludes); + + const result = await service.getById('trip-123'); + + expect(result).toEqual(tripWithIncludes); + expect(prisma.trip.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + include: { + origin: true, + destination: true, + waypoints: { where: { isVisible: true } }, + vehicle: { + select: { + vehicleType: true, + model: { include: { brand: true } }, + manufactureYear: true, + color: true, + }, + }, + }, + }); + }); + }); + + describe('getMultipleById', () => { + it('should get multiple trips by ids', async () => { + const trips = [mockTrip]; + prisma.trip.findMany.mockResolvedValue(trips); + + const result = await service.getMultipleById( + ['trip-123', 'trip-456'], + prisma, + ); + + expect(result).toEqual(trips); + expect(prisma.trip.findMany).toHaveBeenCalledWith({ + where: { + id: { in: ['trip-123', 'trip-456'] }, + status: TripStatusEnum.scheduled, + }, + include: { + origin: true, + destination: true, + waypoints: true, + vehicle: { + include: { + model: { include: { brand: true } }, + }, + }, + matchedRequests: { + include: { + package: { + include: { + originAddress: true, + recipient: { include: { address: true } }, + }, + }, + }, + }, + }, + }); + }); + + it('should default to the injected client when no tx is provided', async () => { + prisma.trip.findMany.mockResolvedValue([mockTrip]); + + const result = await service.getMultipleById(['trip-123']); + + expect(result).toEqual([mockTrip]); + expect(prisma.trip.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: TripStatusEnum.scheduled, + }), + }), + ); + }); + }); + + describe('getAll', () => { + it('should get all trips with default status filter', async () => { + const trips = [mockTrip]; + prisma.trip.findMany.mockResolvedValue(trips); + + const result = await service.getAll('user-123'); + + expect(result).toEqual(trips); + expect(prisma.trip.findMany).toHaveBeenCalledWith({ + where: { + transporter: { userId: 'user-123' }, + status: { + in: [ + TripStatusEnum.scheduled, + TripStatusEnum.closed, + TripStatusEnum.delayed, + TripStatusEnum.in_progress, + ], + }, + deletedAt: null, + }, + include: { + origin: true, + destination: true, + waypoints: { where: { isVisible: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('should get all trips with custom status filter', async () => { + const trips = [mockTrip]; + prisma.trip.findMany.mockResolvedValue(trips); + + await service.getAll('user-123', [TripStatusEnum.scheduled]); + + expect(prisma.trip.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: [TripStatusEnum.scheduled] }, + }), + }), + ); + }); + }); + + describe('update', () => { + const updateDto = { + maxPackageWeightGr: 6000, + description: 'Updated description', + }; + + it('should update trip successfully', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.trip.update.mockResolvedValue({ ...mockTrip, ...updateDto }); + + const result = await service.update('trip-123', updateDto); + + expect(result).toEqual({ ...mockTrip, ...updateDto }); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: updateDto, + }); + }); + + it('should update trip with waypoints', async () => { + const updateDtoWithWaypoints = { + ...updateDto, + waypoints: [{ id: 'way-2', name: 'New waypoint' }], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.tripWaypoint.deleteMany.mockResolvedValue({ count: 1 }); + prisma.trip.update.mockResolvedValue(mockTrip); + + await service.update('trip-123', updateDtoWithWaypoints as any); + + expect(prisma.tripWaypoint.deleteMany).toHaveBeenCalledWith({ + where: { tripId: 'trip-123', isVisible: true }, + }); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: expect.objectContaining({ + waypoints: { + createMany: { + data: [{ id: 'way-2', name: 'New waypoint' }], + }, + }, + }), + }); + }); + + it('should throw when trip status is not scheduled', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + } as any); + + await expect(service.update('trip-123', updateDto)).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.in_progress}*.`, + ), + ); + }); + }); + + describe('delete', () => { + it('should delete trip successfully', async () => { + const deletedTrip = { ...mockTrip, deletedAt: new Date() }; + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.trip.update.mockResolvedValue(deletedTrip); + + const result = await service.delete('trip-123'); + + expect(result).toEqual(deletedTrip); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { deletedAt: expect.any(Date) }, + }); + }); + + it('should throw when trip status is not scheduled', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.completed, + } as any); + + await expect(service.delete('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + }); + + describe('toggleTripAccess', () => { + it('should toggle from scheduled to closed', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.closed, + }); + + const result = await service.toggleTripAccess('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.closed }); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { status: TripStatusEnum.closed }, + }); + }); + + it('should toggle from closed to scheduled', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.closed, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.scheduled, + }); + + const result = await service.toggleTripAccess('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.scheduled }); + }); + + it('should throw when trip status is invalid', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.completed, + } as any); + + await expect(service.toggleTripAccess('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + }); + + describe('startTrip', () => { + it('should start trip successfully', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.scheduled, + origin: mockCity, + } as any); + trackingService.updateTracking.mockResolvedValue({ count: 2 }); + prisma.trip.update.mockResolvedValueOnce({ + ...mockTrip, + status: TripStatusEnum.in_progress, + }); + + const result = await service.startTrip('trip-123'); + + expect(result).toEqual({ + ...mockTrip, + status: TripStatusEnum.in_progress, + }); + expect(trackingService.updateTracking).toHaveBeenCalledWith( + 'trip-123', + { + city: 'Tehran', + description: TrackingMessages.TripStarted, + }, + prisma, + ); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { status: TripStatusEnum.in_progress }, + }); + }); + + it('should throw when trip status cannot start', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.completed, + origin: mockCity, + } as any); + + await expect(service.startTrip('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + }); + + describe('pickupPackage', () => { + it('should pickup package successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( + mockMatchedRequest, + ); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.in_transit, + } as any); + prisma.matchedRequest.update.mockResolvedValue({ + pickupTime: new Date(), + } as any); + prisma.trackingUpdate.create.mockResolvedValue({} as any); + + const result = await service.pickupPackage('trip-123', 'package-123'); + + expect(result.packageStatus).toBe(PackageStatusEnum.in_transit); + expect(result.pickupTime).toBeInstanceOf(Date); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123', deletedAt: null }, + data: { status: PackageStatusEnum.in_transit }, + }); + expect(prisma.trackingUpdate.create).toHaveBeenCalledWith({ + data: { + matchedRequestId: 'matched-123', + latitude: '35.6892', + longitude: '51.3890', + city: 'Tehran', + description: TrackingMessages.PackagePickedUp, + }, + }); + }); + + it('should throw when package status is invalid', async () => { + const invalidMatchedRequest = { + ...mockMatchedRequest, + package: { + ...mockMatchedRequest.package, + status: PackageStatusEnum.delivered, + }, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( + invalidMatchedRequest, + ); + + await expect( + service.pickupPackage('trip-123', 'package-123'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.delivered}*.`, + ), + ); + }); + + it('should throw when trip status is invalid', async () => { + const invalidMatchedRequest = { + ...mockMatchedRequest, + trip: { status: TripStatusEnum.completed }, + }; + + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( + invalidMatchedRequest, + ); + + await expect( + service.pickupPackage('trip-123', 'package-123'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${PackageStatusEnum.matched}*.`, + ), + ); + }); + }); + + describe('deliveryPackage', () => { + const inTransitMatchedRequest = { + ...mockMatchedRequest, + package: { + ...mockMatchedRequest.package, + status: PackageStatusEnum.in_transit, + }, + }; + + it('should deliver package successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + inTransitMatchedRequest, + ); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.delivered, + } as any); + prisma.matchedRequest.update.mockResolvedValue({ + deliveryTime: new Date(), + } as any); + prisma.trackingUpdate.create.mockResolvedValue({} as any); + financialService.releaseEscrow.mockResolvedValue({} as any); + + const result = await service.deliveryPackage( + 'trip-123', + 'package-123', + '12345', + ); + + expect(result.packageStatus).toBe(PackageStatusEnum.delivered); + expect(result.deliveryTime).toBeInstanceOf(Date); + expect(financialService.releaseEscrow).toHaveBeenCalledWith( + 'package-123', + 'trip-123', + prisma, + ); + }); + + it('should throw when delivery code is wrong', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + inTransitMatchedRequest, + ); + + await expect( + service.deliveryPackage('trip-123', 'package-123', '54321'), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.WrongDeliveryCode), + ); + }); + + it('should throw when package is not in transit', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + + await expect( + service.deliveryPackage('trip-123', 'package-123', '12345'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.matched}*.`, + ), + ); + }); + }); + + describe('finishTrip', () => { + it('should finish trip successfully', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [{ deliveryTime: new Date() }], + transporter: { + id: 'transporter-123', + firstTripDate: null, + }, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.completed, + }); + prisma.transporter.update.mockResolvedValue({} as any); + + const result = await service.finishTrip('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); + expect(prisma.transporter.update).toHaveBeenCalledWith({ + where: { id: 'transporter-123' }, + data: { firstTripDate: expect.any(Date) }, + }); + }); + + it('should finish trip successfully when transporter has firstTripDate', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [{ deliveryTime: new Date() }], + transporter: { + id: 'transporter-123', + firstTripDate: new Date('2023-01-01'), + }, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.completed, + }); + prisma.transporter.update.mockResolvedValue({} as any); + + const result = await service.finishTrip('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); + expect(prisma.transporter.update).toHaveBeenCalledWith({ + where: { id: 'transporter-123' }, + data: { lastTripDate: expect.any(Date) }, + }); + }); + + it('should throw when not all packages are delivered', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [{ deliveryTime: null }], + transporter: { + id: 'transporter-123', + firstTripDate: null, + }, + } as any); + + await expect(service.finishTrip('trip-123')).rejects.toThrow( + new BadRequestException(BadRequestMessages.CannotFinishTrip), + ); + }); + + it('should throw when trip is not in progress', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.scheduled, + matchedRequests: [], + transporter: { id: 'transporter-123', firstTripDate: null }, + } as any); + + await expect(service.finishTrip('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.scheduled}*.`, + ), + ); + }); + }); + + describe('Edge cases', () => { + it('should handle empty waypoints array in create', async () => { + const tripDtoWithEmptyWaypoints = { + ...mockCreateTripDto, + waypoints: undefined, + }; + + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + await service.create('user-123', tripDtoWithEmptyWaypoints); + + expect(prisma.trip.create).toHaveBeenCalledWith({ + data: expect.not.objectContaining({ + waypoints: expect.anything(), + }), + }); + }); + }); + + describe('Private method tests', () => { + it('should update package status correctly', async () => { + const updatedPackage = { + id: 'package-123', + status: PackageStatusEnum.in_transit, + }; + prisma.package.update.mockResolvedValue(updatedPackage as any); + + const result = await service['updatePackageStatus']( + 'package-123', + PackageStatusEnum.in_transit, + prisma, + ); + + expect(result).toEqual(updatedPackage); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123', deletedAt: null }, + data: { status: PackageStatusEnum.in_transit }, + }); + }); + + it('should update trip status correctly', async () => { + const updatedTrip = { ...mockTrip, status: TripStatusEnum.completed }; + prisma.trip.update.mockResolvedValue(updatedTrip); + + const result = await service['updateStatus']( + 'trip-123', + TripStatusEnum.completed, + ); + + expect(result).toEqual(updatedTrip); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { status: TripStatusEnum.completed }, + }); + }); + }); +}); diff --git a/src/modules/trip/application/trip.service.ts b/src/modules/trip/application/trip.service.ts new file mode 100644 index 0000000..0846495 --- /dev/null +++ b/src/modules/trip/application/trip.service.ts @@ -0,0 +1,648 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { MapService } from '../../map/map.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CreateTripDto } from '../dto/create-trip.dto'; +import { + AuthMessages, + BadRequestMessages, + TrackingMessages, +} from '../../../common/enums/messages.enum'; +import { + PackageStatusEnum, + Prisma, + TripStatusEnum, + TripTypeEnum, +} from '../../../../generated/prisma'; +import { UpdateTripDto } from '../dto/update-trip.dto'; +import { PrismaTransaction } from '../../prisma/prisma.types'; +import { FinancialService } from '../../financial/financial.service'; +import { NotificationService } from '../../notification/notification.service'; +import { CityRepository } from '../../prisma/repositories/city.repository'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; +import { TripTrackingService } from '../domain/trip-tracking.service'; + +/** + * Trip lifecycle + CRUD orchestration (Phase 4 Task 4). + * + * Slimmed from the 35 KB god service: request handling lives in + * `TripRequestService`, notes/tracking/rating/directions in + * `TripTrackingService`. All multi-statement writes run through + * `TransactionRunner` (no manual `.catch` — the global filter maps Prisma + * errors). Fixes vs the original: `update`/`delete` now write through `tx` + * (they used the root client inside the transaction), and `startTrip` / + * `pickupPackage` / `deliveryPackage` now pass `tx` into the tracking and + * package-status helpers (they silently ran outside the transaction). + */ +@Injectable() +export class TripService { + constructor( + private prisma: PrismaService, + private mapService: MapService, + private financialService: FinancialService, + private notificationService: NotificationService, + private runner: TransactionRunner, + private trackingService: TripTrackingService, + private cities: CityRepository, + ) {} + + async create( + userId: string, + { + vehicleId, + waypoints, + originId, + destinationId, + ...tripDto + }: CreateTripDto, + tripType: TripTypeEnum = TripTypeEnum.intercity, + ) { + return this.runner.run(async (tx) => { + const vehicle = await tx.vehicle.findFirst({ + where: { + id: vehicleId, + owner: { + userId, + }, + }, + }); + + if (!vehicle) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} vehicle.`, + ); + } + + const originCity = await this.cities.findCityOrThrow(originId, tx); + + const destinationCity = await this.cities.findCityOrThrow( + destinationId, + tx, + ); + + const { distance, duration } = await this.mapService.calculateDistance({ + origin: { + latitude: originCity.latitude, + longitude: originCity.longitude, + }, + destination: { + latitude: destinationCity.latitude, + longitude: destinationCity.longitude, + }, + waypoints, + }); + + const tripData = { + transporterId: vehicle.ownerId, + originId, + destinationId, + vehicleId: vehicle.id, + tripType, + normalDistanceKm: distance, + normalDurationMin: duration, + ...tripDto, + } as Prisma.TripUncheckedCreateInput; + + if (waypoints) { + // TODO: Sort waypoints? + tripData.waypoints = { + createMany: { + data: waypoints, + }, + }; + } + + const trip = await tx.trip.create({ + data: tripData, + }); + + // Add create trip notification + await this.notificationService.create( + userId, + { + tripId: trip.id, + content: getNotificationMessage(NotificationMessages.TripCreated, { + tripCode: trip.code, + }), + }, + tx, + ); + + return trip; + }); + } + + async getById(id: string) { + return this.prisma.trip.findUniqueOrThrow({ + where: { id }, + include: { + origin: true, + destination: true, + waypoints: { + where: { + isVisible: true, + }, + }, + vehicle: { + select: { + vehicleType: true, + model: { + include: { + brand: true, + }, + }, + manufactureYear: true, + color: true, + }, + }, + }, + }); + } + + async getMultipleById(ids: string[], tx: PrismaTransaction = this.prisma) { + return tx.trip.findMany({ + where: { + id: { + in: ids, + }, + status: TripStatusEnum.scheduled, + }, + include: { + origin: true, + destination: true, + waypoints: true, + vehicle: { + include: { + model: { + include: { + brand: true, + }, + }, + }, + }, + matchedRequests: { + include: { + package: { + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + }, + }, + }, + }, + }, + }); + } + + async getAll( + userId: string, + status: TripStatusEnum[] = [ + TripStatusEnum.scheduled, + TripStatusEnum.closed, + TripStatusEnum.delayed, + TripStatusEnum.in_progress, + ], + ) { + return this.prisma.trip.findMany({ + where: { + transporter: { + userId, + }, + status: { + in: status, + }, + deletedAt: null, + }, + include: { + origin: true, + destination: true, + waypoints: { + where: { + isVisible: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async update(id: string, { waypoints, ...tripDto }: UpdateTripDto) { + return this.runner.run(async (tx) => { + const { status } = await tx.trip.findUniqueOrThrow({ + where: { + id, + deletedAt: null, + }, + select: { + status: true, + }, + }); + + if (status !== TripStatusEnum.scheduled) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${status}*.`, + ); + } + + const tripData = tripDto as Prisma.TripUpdateInput; + + if (waypoints) { + tripData.waypoints = { + createMany: { + data: waypoints, + }, + }; + + await tx.tripWaypoint.deleteMany({ + where: { + tripId: id, + isVisible: true, + }, + }); + } + + return tx.trip.update({ + where: { id }, + data: tripData, + }); + }); + } + + async delete(id: string) { + return this.runner.run(async (tx) => { + const { status } = await tx.trip.findUniqueOrThrow({ + where: { + id, + deletedAt: null, + }, + select: { + status: true, + }, + }); + + if (status !== TripStatusEnum.scheduled) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${status}*.`, + ); + } + + return tx.trip.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + }); + } + + async toggleTripAccess(id: string) { + const { status: tripStatus } = await this.prisma.trip.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + }, + }); + + const isValidStatus = + tripStatus === TripStatusEnum.scheduled || + tripStatus === TripStatusEnum.closed; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, + ); + } + + const updatedStatus = + tripStatus === TripStatusEnum.scheduled + ? TripStatusEnum.closed + : TripStatusEnum.scheduled; + + return this.updateStatus(id, updatedStatus); + } + + async startTrip(id: string) { + const { status: tripStatus, origin } = + await this.prisma.trip.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + origin: true, + }, + }); + + const isValidStatus = + tripStatus === TripStatusEnum.scheduled || + tripStatus === TripStatusEnum.closed || + tripStatus === TripStatusEnum.delayed; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, + ); + } + + return this.runner.run(async (tx) => { + await this.trackingService.updateTracking( + id, + { + city: origin.name, + description: TrackingMessages.TripStarted, + }, + tx, + ); + + return this.updateStatus(id, TripStatusEnum.in_progress, tx); + }); + } + + async pickupPackage(tripId: string, packageId: string) { + const { + id: matchedRequestId, + package: packageData, + trip, + } = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + tripId, + packageId, + }, + select: { + id: true, + package: { + select: { + code: true, + senderId: true, + status: true, + originAddress: true, + }, + }, + trip: { + select: { + status: true, + }, + }, + }, + }); + + if (packageData.status !== PackageStatusEnum.matched) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, + ); + } + + if (trip.status !== TripStatusEnum.in_progress) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${packageData.status}*.`, + ); + } + + return this.runner.run(async (tx) => { + // Update package status + const { status: packageStatus } = await this.updatePackageStatus( + packageId, + PackageStatusEnum.in_transit, + tx, + ); + + // Set pickupTime + const { pickupTime } = await tx.matchedRequest.update({ + where: { + tripId, + packageId, + }, + data: { + pickupTime: new Date(), + }, + }); + + // Update tracking + await tx.trackingUpdate.create({ + data: { + matchedRequestId, + latitude: packageData.originAddress.latitude, + longitude: packageData.originAddress.longitude, + city: packageData.originAddress.city, + description: TrackingMessages.PackagePickedUp, + }, + }); + + // Add pickup package notification + await this.notificationService.create( + packageData.senderId, + { + packageId, + tripId, + content: getNotificationMessage( + NotificationMessages.PackagePickedUp, + { packageCode: packageData.code }, + ), + }, + tx, + ); + + return { + packageStatus, + pickupTime, + }; + }); + } + + async deliveryPackage(tripId: string, packageId: string, code: string) { + const { + id: matchedRequestId, + package: packageData, + trip, + deliveryCode, + } = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + tripId, + packageId, + }, + select: { + id: true, + package: { + select: { + code: true, + senderId: true, + status: true, + recipient: { + include: { + address: true, + }, + }, + }, + }, + trip: { + select: { + status: true, + }, + }, + deliveryCode: true, + }, + }); + + if (packageData.status !== PackageStatusEnum.in_transit) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, + ); + } + + if (trip.status !== TripStatusEnum.in_progress) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, + ); + } + + if (code !== deliveryCode) { + throw new BadRequestException(BadRequestMessages.WrongDeliveryCode); + } + + return this.runner.run(async (tx) => { + // Update package status + const { status: packageStatus } = await this.updatePackageStatus( + packageId, + PackageStatusEnum.delivered, + tx, + ); + + // Set deliveryTime + const { deliveryTime } = await tx.matchedRequest.update({ + where: { + tripId, + packageId, + }, + data: { + deliveryTime: new Date(), + }, + }); + + // Update tracking + await tx.trackingUpdate.create({ + data: { + matchedRequestId, + latitude: packageData.recipient.address.latitude, + longitude: packageData.recipient.address.longitude, + city: packageData.recipient.address.city, + description: TrackingMessages.PackageDelivered, + }, + }); + + // Release escrow (fatal: aborts the transaction on failure) + await this.financialService.releaseEscrow(packageId, tripId, tx); + + // TODO: Send SMS + + // Add delivery package notification + await this.notificationService.create( + packageData.senderId, + { + packageId, + tripId, + content: getNotificationMessage( + NotificationMessages.PackageDelivered, + { packageCode: packageData.code }, + ), + }, + tx, + ); + + return { + packageStatus, + deliveryTime, + }; + }); + } + + async finishTrip(id: string) { + const { + status: tripStatus, + matchedRequests, + transporter, + } = await this.prisma.trip.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + matchedRequests: { + select: { + deliveryTime: true, + }, + }, + transporter: { + select: { + id: true, + firstTripDate: true, + }, + }, + }, + }); + + if (tripStatus !== TripStatusEnum.in_progress) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, + ); + } + + const allRequestsDelivered = matchedRequests.every( + (m) => m.deliveryTime !== null, + ); + if (!allRequestsDelivered) { + throw new BadRequestException(BadRequestMessages.CannotFinishTrip); + } + + // Update firstTripDate/lastTripDate + const updateTransporter: Prisma.TransporterUpdateInput = {}; + if (!transporter.firstTripDate) { + updateTransporter.firstTripDate = new Date(); + } else { + updateTransporter.lastTripDate = new Date(); + } + + return this.runner.run(async (tx) => { + // Update transporter + await tx.transporter.update({ + where: { id: transporter.id }, + data: updateTransporter, + }); + + return this.updateStatus(id, TripStatusEnum.completed, tx); + }); + } + + private async updateStatus( + id: string, + status: TripStatusEnum, + tx: PrismaTransaction = this.prisma, + ) { + return tx.trip.update({ + where: { id }, + data: { status }, + }); + } + + private async updatePackageStatus( + id: string, + status: PackageStatusEnum, + tx: PrismaTransaction, + ) { + return tx.package.update({ + where: { + id, + deletedAt: null, + }, + data: { + status, + }, + }); + } +} diff --git a/src/modules/trip/domain/matched-route-sorter.spec.ts b/src/modules/trip/domain/matched-route-sorter.spec.ts new file mode 100644 index 0000000..1df1a70 --- /dev/null +++ b/src/modules/trip/domain/matched-route-sorter.spec.ts @@ -0,0 +1,136 @@ +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PackageStatusEnum } from '../../../../generated/prisma'; +import { TurfService } from '../../turf/turf.service'; +import { MatchedRouteSorter } from './matched-route-sorter'; + +describe('MatchedRouteSorter', () => { + let sorter: MatchedRouteSorter; + let turfService: DeepMockProxy; + + const origin = { latitude: '35.65', longitude: '51.35' }; + const destination = { latitude: '35.95', longitude: '51.65' }; + + function matchedRequest( + id: string, + status: PackageStatusEnum, + overrides: Record = {}, + ) { + return { + package: { + id, + status, + originAddress: { latitude: '35.7', longitude: '51.4' }, + recipient: { address: { latitude: '35.8', longitude: '51.5' } }, + pickupAtOrigin: true, + deliveryAtDestination: true, + ...overrides, + }, + }; + } + + beforeEach(() => { + turfService = mockDeep(); + sorter = new MatchedRouteSorter(turfService); + }); + + describe('sort', () => { + it('should map pickup points for active packages and reorder by route', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.matched), + matchedRequest('package-2', PackageStatusEnum.matched), + ]; + turfService.sortLocationsByRoute.mockReturnValue( + new Map([ + ['package-2', { latitude: 'x', longitude: 'y' }], + ['package-1', { latitude: 'x', longitude: 'y' }], + ]) as never, + ); + + const result = await sorter.sort(origin, destination, requests); + + expect(turfService.sortLocationsByRoute).toHaveBeenCalledWith( + origin, + destination, + new Map([ + ['package-1', { latitude: '35.7', longitude: '51.4' }], + ['package-2', { latitude: '35.7', longitude: '51.4' }], + ]), + ); + expect(result.map((r) => r.package.id)).toEqual([ + 'package-2', + 'package-1', + ]); + }); + + it('should map delivery points for matched packages without pickup', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.matched, { + pickupAtOrigin: false, + }), + ]; + turfService.sortLocationsByRoute.mockReturnValue( + new Map([['package-1', { latitude: 'x', longitude: 'y' }]]) as never, + ); + + await sorter.sort(origin, destination, requests); + + expect(turfService.sortLocationsByRoute).toHaveBeenCalledWith( + origin, + destination, + new Map([['package-1', { latitude: '35.8', longitude: '51.5' }]]), + ); + }); + + it('should drop delivered, returned and cancelled packages from pickup stops', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.delivered), + matchedRequest('package-2', PackageStatusEnum.returned), + matchedRequest('package-3', PackageStatusEnum.cancelled), + matchedRequest('package-4', PackageStatusEnum.matched), + ]; + turfService.sortLocationsByRoute.mockReturnValue(new Map() as never); + + const result = await sorter.sort(origin, destination, requests); + + const sentMap = turfService.sortLocationsByRoute.mock.calls[0][2] as Map< + string, + unknown + >; + expect(Array.from(sentMap.keys())).toEqual(['package-4']); + expect(result).toEqual([]); + }); + + it('should drop packages with neither pickup nor delivery stop', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.in_transit, { + pickupAtOrigin: false, + deliveryAtDestination: false, + }), + ]; + turfService.sortLocationsByRoute.mockReturnValue(new Map() as never); + + const result = await sorter.sort(origin, destination, requests); + + const sentMap = turfService.sortLocationsByRoute.mock.calls[0][2] as Map< + string, + unknown + >; + expect(Array.from(sentMap.keys())).toEqual([]); + expect(result).toEqual([]); + }); + + it('should skip sorted ids with no matching request', async () => { + const requests = [matchedRequest('package-1', PackageStatusEnum.matched)]; + turfService.sortLocationsByRoute.mockReturnValue( + new Map([ + ['package-ghost', { latitude: 'x', longitude: 'y' }], + ['package-1', { latitude: '35.7', longitude: '51.4' }], + ]) as never, + ); + + const result = await sorter.sort(origin, destination, requests); + + expect(result.map((r) => r.package.id)).toEqual(['package-1']); + }); + }); +}); diff --git a/src/modules/trip/domain/matched-route-sorter.ts b/src/modules/trip/domain/matched-route-sorter.ts new file mode 100644 index 0000000..6a1af35 --- /dev/null +++ b/src/modules/trip/domain/matched-route-sorter.ts @@ -0,0 +1,65 @@ +import { PackageStatusEnum } from '../../../../generated/prisma'; +import { Location } from '../../map/map.types'; +import { TurfService } from '../../turf/turf.service'; + +/** + * Route-ordering for matched packages (Phase 4 Task 4). + * + * Extracted verbatim from `TripService.sortMatchedPackages`: builds a + * packageId → stop-location map (pickup point for not-yet-picked-up packages, + * delivery point for matched packages awaiting delivery), orders it along the + * trip route via Turf, and reorders the matched requests to match. + */ +export class MatchedRouteSorter { + constructor(private readonly turfService: TurfService) {} + + async sort( + origin: Location, + destination: Location, + matchedRequests: any[], + ): Promise { + const locationsMap = new Map( + matchedRequests + .map((m) => [ + m.package.id, + m.package.pickupAtOrigin && + m.package.status !== PackageStatusEnum.delivered && + m.package.status !== PackageStatusEnum.returned && + m.package.status !== PackageStatusEnum.cancelled + ? { + latitude: m.package.originAddress.latitude, + longitude: m.package.originAddress.longitude, + } + : m.package.deliveryAtDestination && + m.package.status === PackageStatusEnum.matched + ? { + latitude: m.package.recipient.address.latitude, + longitude: m.package.recipient.address.longitude, + } + : undefined, + ]) + .filter(([_, location]) => location !== undefined) as [ + string, + Location, + ][], + ); + + const sortedLocations = this.turfService.sortLocationsByRoute( + origin, + destination, + locationsMap, + ); + const sortedPackageIds = Array.from(sortedLocations.keys()); + + // Create the sorted array + const sortedRequests: any[] = []; + sortedPackageIds.forEach((packageId) => { + const request = matchedRequests.find((m) => m.package.id === packageId); + if (request) { + sortedRequests.push(request); + } + }); + + return sortedRequests; + } +} diff --git a/src/modules/trip/domain/trip-request.service.spec.ts b/src/modules/trip/domain/trip-request.service.spec.ts new file mode 100644 index 0000000..6477d04 --- /dev/null +++ b/src/modules/trip/domain/trip-request.service.spec.ts @@ -0,0 +1,507 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripRequestService } from './trip-request.service'; +import { S3Service } from '../../s3/s3.service'; +import { FinancialService } from '../../financial/financial.service'; +import { + PrismaClient, + TripStatusEnum, + RequestStatusEnum, + PackageStatusEnum, +} from '../../../../generated/prisma'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import * as utilities from '../../../common/utilities'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; + +jest.mock('../../../common/utilities', () => ({ + generateCode: jest.fn(() => 12345), + generateUniqueCode: jest.fn(() => '17571445988911932924'), +})); + +describe('TripRequestService', () => { + let service: TripRequestService; + let prisma: DeepMockProxy; + let financialService: DeepMockProxy; + let s3Service: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + let turfService: DeepMockProxy; + + const mockTrip = { + id: 'trip-123', + status: TripStatusEnum.scheduled, + } as any; + + const mockTripRequest = { + id: 'request-123', + tripId: 'trip-123', + packageId: 'package-123', + status: RequestStatusEnum.pending, + deviationDistanceKm: 10, + deviationDurationMin: 15, + deviationCost: 50000, + } as any; + + const mockMatchedRequest = { + id: 'matched-123', + tripId: 'trip-123', + packageId: 'package-123', + trackingCode: '17571445988911932924', + deliveryCode: '12345', + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + financialService = mockDeep(); + s3Service = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + turfService = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + // Reset utility mocks to default values + (utilities.generateCode as jest.Mock).mockReturnValue(12345); + (utilities.generateUniqueCode as jest.Mock).mockReturnValue( + '17571445988911932924', + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TripRequestService, + { provide: PrismaService, useValue: prisma }, + { provide: FinancialService, useValue: financialService }, + { provide: S3Service, useValue: s3Service }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + { provide: TurfService, useValue: turfService }, + ], + }).compile(); + + service = module.get(TripRequestService); + }); + + describe('updateRequest', () => { + it('should reject request', async () => { + const updatedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.rejected, + }; + prisma.tripRequest.update.mockResolvedValue({ + ...updatedRequest, + package: { code: 'PKG-123', senderId: 'user-123' }, + trip: { code: 'TRIP-123' }, + }); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.rejected, + } as any); + + expect(result).toEqual(updatedRequest); + expect(prisma.tripRequest.update).toHaveBeenCalledWith({ + where: { id: 'request-123' }, + data: { status: RequestStatusEnum.rejected }, + include: { + package: { + select: { + code: true, + senderId: true, + }, + }, + trip: { + select: { + code: true, + }, + }, + }, + }); + }); + + it('should accept request and create matched request', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: { baseCost: 100000, deviationCost: 0 }, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + financialService.createEscrow.mockResolvedValue({} as any); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.accepted, + transporterNotes: ['Note 1'], + } as any); + + expect(result).toEqual(acceptedRequest); + expect(prisma.matchedRequest.create).toHaveBeenCalledWith({ + data: { + requestId: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + trackingCode: mockMatchedRequest.trackingCode, + deliveryCode: mockMatchedRequest.deliveryCode, + transporterNotes: ['Note 1'], + }, + }); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123' }, + data: { + status: PackageStatusEnum.matched, + finalPrice: { increment: 50000 }, + breakdown: expect.any(Object), + }, + }); + }); + + it('should treat a non-rejected status as accept', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: { baseCost: 100000, deviationCost: 0 }, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + financialService.createEscrow.mockResolvedValue({} as any); + + const result = await service.updateRequest('request-123', { + status: 'unknown-status', + } as any); + + expect(result).toEqual(acceptedRequest); + expect(prisma.matchedRequest.create).toHaveBeenCalled(); + }); + + it('should handle null totals and breakdown in accept', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: null, + totalDeviationDurationMin: null, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: null, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + financialService.createEscrow.mockResolvedValue({} as any); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.accepted, + } as any); + + expect(result).toEqual(acceptedRequest); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { + totalDeviationDistanceKm: 10, // 0 + 10 + totalDeviationDurationMin: 15, // 0 + 15 + }, + }); + }); + + it('should handle escrow creation failure gracefully', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: { baseCost: 100000, deviationCost: 0 }, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + + financialService.createEscrow.mockRejectedValue( + new Error('Insufficient balance'), + ); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.accepted, + } as any); + + expect(result).toEqual(acceptedRequest); + expect(financialService.createEscrow).toHaveBeenCalled(); + }); + + it('should run accept and reject inside TransactionRunner', async () => { + prisma.tripRequest.update.mockResolvedValue({ + ...mockTripRequest, + package: { code: 'PKG-123', senderId: 'user-123' }, + trip: { code: 'TRIP-123' }, + }); + + await service.updateRequest('request-123', { + status: RequestStatusEnum.rejected, + } as any); + + expect(runner.run).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAllTripRequests', () => { + it('should get all pending trip requests', async () => { + const requests = [mockTripRequest]; + prisma.tripRequest.findMany.mockResolvedValue(requests); + + const result = await service.getAllTripRequests('trip-123'); + + expect(result).toEqual(requests); + expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ + where: { tripId: 'trip-123', status: RequestStatusEnum.pending }, + orderBy: { createdAt: 'asc' }, + }); + }); + }); + + describe('getAllMatchedRequests', () => { + it('should get all matched requests for a trip', async () => { + const matchedRequests = [ + { + package: { + id: 'package-123', + sender: { firstName: 'Ahmad', lastName: 'Mohammadi' }, + items: ['Electronics'], + weight: 2.5, + picturesKey: ['key-1'], + }, + request: { offeredPrice: 75000 }, + transporterNotes: ['Handle with care'], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + }, + ]; + + prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); + s3Service.generateGetPresignedUrl.mockResolvedValue( + 'https://s3.example.com/key-1', + ); + + const result = await service.getAllMatchedRequests('trip-123'); + + expect(result).toEqual([ + { + package: { + ...matchedRequests[0].package, + picturesUrl: ['https://s3.example.com/key-1'], + offeredPrice: 75000, + picturesKey: undefined, + }, + transporterNotes: ['Handle with care'], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + request: undefined, + }, + ]); + expect(prisma.matchedRequest.findMany).toHaveBeenCalledWith({ + where: { tripId: 'trip-123' }, + orderBy: { updatedAt: 'desc' }, + select: { + package: { + select: { + id: true, + code: true, + sender: { + select: { + firstName: true, + lastName: true, + gender: true, + phoneNumber: true, + }, + }, + status: true, + items: true, + originAddress: true, + recipient: { select: { address: true } }, + weight: true, + dimensions: true, + packageValue: true, + isFragile: true, + isPerishable: true, + description: true, + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: true, + preferredDeliveryTime: true, + picturesKey: true, + }, + }, + request: true, + transporterNotes: true, + pickupTime: true, + deliveryTime: true, + paymentStatus: true, + }, + }); + }); + + it('should return matched requests in route order when inOrder is true', async () => { + const matchedRequests = [ + { + package: { + id: 'package-1', + code: 'PKG-1', + sender: { + firstName: 'A', + lastName: 'A', + gender: 'male', + phoneNumber: '+1', + }, + status: PackageStatusEnum.matched, + items: ['Item1'], + originAddress: { + latitude: '35.7', + longitude: '51.4', + city: 'Tehran', + }, + recipient: { + address: { latitude: '35.8', longitude: '51.5', city: 'Tehran' }, + }, + weight: 1, + dimensions: { w: 1, h: 1, l: 1 } as any, + packageValue: 100, + isFragile: false, + isPerishable: false, + description: 'desc1', + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: null, + preferredDeliveryTime: null, + picturesKey: ['k1'], + }, + request: { offeredPrice: 10000 }, + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + }, + { + package: { + id: 'package-2', + code: 'PKG-2', + sender: { + firstName: 'B', + lastName: 'B', + gender: 'female', + phoneNumber: '+2', + }, + status: PackageStatusEnum.matched, + items: ['Item2'], + originAddress: { + latitude: '35.6', + longitude: '51.3', + city: 'Tehran', + }, + recipient: { + address: { latitude: '35.9', longitude: '51.6', city: 'Tehran' }, + }, + weight: 2, + dimensions: { w: 2, h: 2, l: 2 } as any, + packageValue: 200, + isFragile: true, + isPerishable: false, + description: 'desc2', + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: null, + preferredDeliveryTime: null, + picturesKey: ['k2'], + }, + request: { offeredPrice: 20000 }, + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + }, + ]; + + prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); + prisma.trip.findFirstOrThrow.mockResolvedValue({ + origin: { latitude: '35.65', longitude: '51.35', name: 'Origin' }, + destination: { + latitude: '35.95', + longitude: '51.65', + name: 'Destination', + }, + } as any); + + (turfService.sortLocationsByRoute as any).mockImplementationOnce( + (_o, _d, _locationsMap: Map) => + new Map([ + ['package-2', { latitude: '35.6', longitude: '51.3' }], + ['package-1', { latitude: '35.7', longitude: '51.4' }], + ]), + ); + + s3Service.generateGetPresignedUrl.mockImplementation( + async (key: string) => `https://s3.example.com/${key}`, + ); + + const result = await service.getAllMatchedRequests('trip-123', true); + + expect(result.map((r) => r.package.id)).toEqual([ + 'package-2', + 'package-1', + ]); + expect(result[0].package.picturesUrl).toEqual([ + 'https://s3.example.com/k2', + ]); + expect(result[1].package.picturesUrl).toEqual([ + 'https://s3.example.com/k1', + ]); + }); + }); +}); diff --git a/src/modules/trip/domain/trip-request.service.ts b/src/modules/trip/domain/trip-request.service.ts new file mode 100644 index 0000000..5769a6d --- /dev/null +++ b/src/modules/trip/domain/trip-request.service.ts @@ -0,0 +1,319 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { generateCode, generateUniqueCode } from '../../../common/utilities'; +import { UpdateRequestDto } from '../dto/update-request.dto'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { PriceBreakdownDto } from '../../package/dto/package-response.dto'; +import { + PackageStatusEnum, + RequestStatusEnum, +} from '../../../../generated/prisma'; +import { FinancialService } from '../../financial/financial.service'; +import { S3Service } from '../../s3/s3.service'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; +import { MatchedRouteSorter } from './matched-route-sorter'; + +/** + * Trip-side request handling (Phase 4 Task 4). + * + * Owns `updateRequest` (accept/reject), the pending-request list and the + * matched-request list. All multi-statement writes run through + * `TransactionRunner` (no manual `.catch` — the global filter maps Prisma + * errors). Fix vs the original: the reject branch now writes through `tx` + * (it used the root client inside the transaction). + * + * Escrow semantics preserved: creation on accept is intentionally non-fatal + * (MVP — the request is still accepted), while release on delivery (in the + * application service) is fatal. + */ +@Injectable() +export class TripRequestService { + private readonly sorter: MatchedRouteSorter; + + constructor( + private prisma: PrismaService, + private financialService: FinancialService, + private s3Service: S3Service, + private notificationService: NotificationService, + private runner: TransactionRunner, + turfService: TurfService, + ) { + this.sorter = new MatchedRouteSorter(turfService); + } + + async updateRequest( + requestId: string, + { status, transporterNotes }: UpdateRequestDto, + ) { + if ( + (status as unknown as RequestStatusEnum) === RequestStatusEnum.rejected + ) { + return this.runner.run(async (tx) => { + const { + package: packageData, + trip, + ...request + } = await tx.tripRequest.update({ + where: { id: requestId }, + data: { status }, + include: { + package: { + select: { + code: true, + senderId: true, + }, + }, + trip: { + select: { + code: true, + }, + }, + }, + }); + + // Add reject request notification + await this.notificationService.create( + packageData.senderId, + { + packageId: request.packageId, + tripId: request.tripId, + content: getNotificationMessage( + NotificationMessages.TripRequestRejected, + { + packageCode: packageData.code, + tripCode: trip.code, + }, + ), + }, + tx, + ); + + return request; + }); + } + + return this.runner.run(async (tx) => { + const request = await tx.tripRequest.update({ + where: { id: requestId }, + data: { status }, + }); + + // Delete other sent requests for this package + await tx.tripRequest.updateMany({ + where: { + packageId: request.packageId, + NOT: { + id: request.id, + }, + }, + data: { + status: RequestStatusEnum.deleted, + }, + }); + + // Create MatchedRequest instance + const trackingCode = generateUniqueCode(); + const deliveryCode = generateCode().toString(); + await tx.matchedRequest.create({ + data: { + requestId: request.id, + packageId: request.packageId, + tripId: request.tripId, + trackingCode, + deliveryCode, + transporterNotes, + }, + }); + + // Update total deviation info in trip + const { totalDeviationDistanceKm, totalDeviationDurationMin } = + await tx.trip.findUniqueOrThrow({ + where: { id: request.tripId }, + }); + + const newTotalDeviationDistance = + (totalDeviationDistanceKm ?? 0) + request.deviationDistanceKm; + const newTotalDeviationDuration = + (totalDeviationDurationMin ?? 0) + request.deviationDurationMin; + + const { code: tripCode } = await tx.trip.update({ + where: { id: request.tripId }, + data: { + totalDeviationDistanceKm: newTotalDeviationDistance, + totalDeviationDurationMin: newTotalDeviationDuration, + }, + }); + + // Get package and Update its status and breakdown + const packageData = await tx.package.findFirstOrThrow({ + where: { id: request.packageId }, + select: { + code: true, + breakdown: true, + senderId: true, + }, + }); + + // Update breakdown + const breakdown = plainToInstance( + PriceBreakdownDto, + packageData?.breakdown, + ); + if (breakdown) { + breakdown.deviationCost = request.deviationCost ?? 0; + } + const plainBreakdown = instanceToPlain(breakdown); + + await tx.package.update({ + where: { id: request.packageId }, + data: { + status: PackageStatusEnum.matched, + finalPrice: { + increment: request.deviationCost, + }, + breakdown: plainBreakdown, + }, + }); + + // Create escrow if balance is enough + try { + await this.financialService.createEscrow({ + packageId: request.packageId, + tripId: request.tripId, + }); + } catch { + // Escrow creation failure is intentionally non-fatal here (MVP behavior); request is still accepted. + } + + // Add accept request notification + await this.notificationService.create( + packageData.senderId, + { + packageId: request.packageId, + tripId: request.tripId, + content: getNotificationMessage( + NotificationMessages.TripRequestAccepted, + { + packageCode: packageData.code, + tripCode, + }, + ), + }, + tx, + ); + + return request; + }); + } + + async getAllTripRequests(tripId: string) { + return this.prisma.tripRequest.findMany({ + where: { + tripId, + status: RequestStatusEnum.pending, + }, + orderBy: { + createdAt: 'asc', + }, + }); + } + + async getAllMatchedRequests(tripId: string, inOrder = false) { + const matchedRequests = await this.prisma.matchedRequest.findMany({ + where: { + tripId, + }, + select: { + package: { + select: { + id: true, + code: true, + sender: { + select: { + firstName: true, + lastName: true, + gender: true, + phoneNumber: true, + }, + }, + status: true, + items: true, + originAddress: true, + recipient: { + select: { + address: true, + }, + }, + weight: true, + dimensions: true, + packageValue: true, + isFragile: true, + isPerishable: true, + description: true, + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: true, + preferredDeliveryTime: true, + picturesKey: true, + }, + }, + request: true, + transporterNotes: true, + pickupTime: true, + deliveryTime: true, + paymentStatus: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + let sortedMatchedRequests = matchedRequests.map((m) => ({ + ...m, + package: { + ...m.package, + items: instanceToPlain(m.package.items) as string[], + }, + })); + if (matchedRequests.length > 0 && inOrder) { + const trip = await this.prisma.trip.findFirstOrThrow({ + where: { id: tripId }, + select: { + origin: true, + destination: true, + }, + }); + sortedMatchedRequests = await this.sorter.sort( + trip.origin, + trip.destination, + sortedMatchedRequests, + ); + } + + return Promise.all( + sortedMatchedRequests.map(async (m) => { + const picturesKey = instanceToPlain(m.package.picturesKey) as string[]; + const picturePromises = picturesKey?.map((k) => + this.s3Service.generateGetPresignedUrl(k), + ); + const picturesUrl = await Promise.all(picturePromises); + return { + ...m, + package: { + ...m.package, + picturesUrl, + offeredPrice: m.request.offeredPrice, + picturesKey: undefined, + }, + request: undefined, + }; + }), + ); + } +} diff --git a/src/modules/trip/domain/trip-tracking.service.spec.ts b/src/modules/trip/domain/trip-tracking.service.spec.ts new file mode 100644 index 0000000..afa3215 --- /dev/null +++ b/src/modules/trip/domain/trip-tracking.service.spec.ts @@ -0,0 +1,398 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripTrackingService } from './trip-tracking.service'; +import { MapService } from '../../map/map.service'; +import { + PrismaClient, + TripStatusEnum, + PackageStatusEnum, +} from '../../../../generated/prisma'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestMessages, + AuthMessages, +} from '../../../common/enums/messages.enum'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; + +describe('TripTrackingService', () => { + let service: TripTrackingService; + let prisma: DeepMockProxy; + let mapService: DeepMockProxy; + let turfService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + + const mockTrip = { + id: 'trip-123', + status: TripStatusEnum.scheduled, + } as any; + + const trackingDto = { + latitude: '35.6892', + longitude: '51.3890', + city: 'Tehran', + routeName: 'Azadi Square', + description: 'Moving towards destination', + }; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + mapService = mockDeep(); + turfService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TripTrackingService, + { provide: PrismaService, useValue: prisma }, + { provide: MapService, useValue: mapService }, + { provide: TurfService, useValue: turfService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + ], + }).compile(); + + service = module.get(TripTrackingService); + }); + + describe('addTripNote', () => { + it('should add note to specific package', async () => { + const tripWithMatches = { + ...mockTrip, + matchedRequests: [ + { + packageId: 'package-123', + transporterNotes: [], + package: { senderId: 'user-123' }, + }, + ], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); + prisma.matchedRequest.update.mockResolvedValue({} as any); + + const result = await service.addTripNote( + 'trip-123', + 'Test note', + 'package-123', + ); + + expect(result.count).toBe(1); + expect(prisma.matchedRequest.update).toHaveBeenCalledWith({ + where: { tripId: 'trip-123', packageId: 'package-123' }, + data: { transporterNotes: ['Test note'] }, + }); + }); + + it('should throw when trip is completed', async () => { + const completedTrip = { + ...mockTrip, + status: TripStatusEnum.completed, + matchedRequests: [], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(completedTrip); + + await expect( + service.addTripNote('trip-123', 'Test note'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + + it('should handle empty matched requests in addTripNote', async () => { + const tripWithoutMatches = { + ...mockTrip, + matchedRequests: [], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithoutMatches); + + const result = await service.addTripNote('trip-123', 'Test note'); + + expect(result.count).toBe(0); + }); + + it('should handle partial delivery failures in addTripNote', async () => { + jest.spyOn(Promise, 'allSettled').mockResolvedValue([ + { status: 'fulfilled', value: { id: 'matched-1' } as any }, + { status: 'rejected', reason: new Error('Database error') }, + { status: 'fulfilled', value: { id: 'matched-3' } as any }, + ]); + + const tripWithMatches = { + ...mockTrip, + matchedRequests: [ + { + packageId: 'package-1', + transporterNotes: [], + package: { senderId: 'user-1' }, + }, + { + packageId: 'package-2', + transporterNotes: [], + package: { senderId: 'user-2' }, + }, + { + packageId: 'package-3', + transporterNotes: [], + package: { senderId: 'user-3' }, + }, + ], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); + + const result = await service.addTripNote('trip-123', 'Broadcast note'); + + expect(result.count).toBe(2); + + jest.restoreAllMocks(); + }); + }); + + describe('updateTracking', () => { + it('should update tracking successfully', async () => { + const tripWithMatches = { + status: TripStatusEnum.in_progress, + matchedRequests: [{ id: 'matched-1' }, { id: 'matched-2' }], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce( + tripWithMatches as any, + ); + prisma.trackingUpdate.createMany.mockResolvedValue({ count: 2 }); + + const result = await service.updateTracking('trip-123', trackingDto); + + expect(result.count).toBe(2); + expect(prisma.trackingUpdate.createMany).toHaveBeenCalledWith({ + data: [ + { matchedRequestId: 'matched-1', ...trackingDto }, + { matchedRequestId: 'matched-2', ...trackingDto }, + ], + }); + }); + + it('should default to the injected client when no tx is provided', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [], + } as any); + prisma.trackingUpdate.createMany.mockResolvedValue({ count: 0 }); + + const result = await service.updateTracking('trip-123', trackingDto); + + expect(result).toEqual({ count: 0 }); + expect(prisma.trip.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + select: { status: true, matchedRequests: true }, + }); + }); + }); + + describe('getTripTracking', () => { + it('should get trip tracking successfully', async () => { + const trackingUpdates = [ + { id: 'tracking-1', city: 'Tehran', createdAt: new Date() }, + ]; + prisma.trackingUpdate.findMany.mockResolvedValue(trackingUpdates as any); + + const result = await service.getTripTracking('trip-123', 'package-123'); + + expect(result).toEqual(trackingUpdates); + expect(prisma.trackingUpdate.findMany).toHaveBeenCalledWith({ + where: { + matchedRequest: { tripId: 'trip-123', packageId: 'package-123' }, + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + }); + + describe('rateTrip', () => { + const rateDto = { + tripId: 'trip-123', + packageId: 'package-123', + rate: 5, + comment: 'Great service!', + }; + + const mockRatingData = { + senderRating: null, + package: { + senderId: 'user-123', + status: PackageStatusEnum.delivered, + }, + trip: { + transporterId: 'transporter-123', + transporter: { + rate: 4.5, + rateCount: 10, + }, + }, + }; + + it('should rate trip successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockRatingData as any, + ); + prisma.transporter.update.mockResolvedValue({} as any); + prisma.matchedRequest.update.mockResolvedValue({ + senderRating: 5, + } as any); + + const result = await service.rateTrip('user-123', rateDto); + + expect(result.senderRating).toBe(5); + expect(prisma.transporter.update).toHaveBeenCalledWith({ + where: { id: 'transporter-123' }, + data: { + rate: (4.5 * 10 + 5) / 11, // New average + rateCount: 11, + }, + }); + }); + + it('should throw when user is not the sender', async () => { + const wrongUserRatingData = { + ...mockRatingData, + package: { ...mockRatingData.package, senderId: 'different-user' }, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + wrongUserRatingData as any, + ); + + await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), + ); + }); + + it('should throw when trip already rated', async () => { + const alreadyRatedData = { + ...mockRatingData, + senderRating: 4, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + alreadyRatedData as any, + ); + + await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( + new BadRequestException(BadRequestMessages.AlreadyRatedTrip), + ); + }); + + it('should throw when package status is invalid', async () => { + const invalidStatusData = { + ...mockRatingData, + package: { + ...mockRatingData.package, + status: PackageStatusEnum.matched, + }, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + invalidStatusData as any, + ); + + await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.matched}*.`, + ), + ); + }); + }); + + describe('getDirections', () => { + it('should build sorted waypoints and fetch directions', async () => { + const origin = { + latitude: '35.60', + longitude: '51.30', + name: 'Start', + } as any; + + prisma.trip.findFirstOrThrow.mockResolvedValue({ + destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, + } as any); + + // Two matched requests with pickup and delivery points + prisma.matchedRequest.findMany.mockResolvedValue([ + { + package: { + id: 'package-1', + status: PackageStatusEnum.matched, + originAddress: { latitude: '35.70', longitude: '51.40' }, + recipient: { address: { latitude: '35.80', longitude: '51.50' } }, + pickupAtOrigin: true, + deliveryAtDestination: true, + }, + }, + { + package: { + id: 'package-2', + status: PackageStatusEnum.matched, + originAddress: { latitude: '35.65', longitude: '51.35' }, + recipient: { address: { latitude: '35.90', longitude: '51.60' } }, + pickupAtOrigin: true, + deliveryAtDestination: true, + }, + }, + ] as any); + + // First call used by the route sorter: return an ordering map keyed by package ids + (turfService.sortLocationsByRoute as any).mockImplementationOnce( + (_o, _d, _map) => + new Map([ + ['package-2', { latitude: '35.65', longitude: '51.35' }], + ['package-1', { latitude: '35.70', longitude: '51.40' }], + ]), + ); + + // Second call sorts the final waypoints array + (turfService.sortLocationsByRoute as any).mockImplementationOnce( + (_o, _d, waypoints: any[]) => waypoints, + ); + + const mockedDirections = { polyline: 'encoded-polyline' } as any; + mapService.getDirections.mockResolvedValue(mockedDirections); + + const result = await service.getDirections('trip-123', origin); + + expect(mapService.getDirections).toHaveBeenCalledWith({ + origin, + destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, + waypoints: [ + { latitude: '35.65', longitude: '51.35' }, + { latitude: '35.90', longitude: '51.60' }, + { latitude: '35.70', longitude: '51.40' }, + { latitude: '35.80', longitude: '51.50' }, + ], + }); + expect(result).toBe(mockedDirections); + }); + + it('should return empty object when trip has no matched requests', async () => { + const origin = { latitude: '35.60', longitude: '51.30' } as any; + + prisma.trip.findFirstOrThrow.mockResolvedValue({ + destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, + } as any); + prisma.matchedRequest.findMany.mockResolvedValue([]); + + const result = await service.getDirections('trip-123', origin); + + expect(result).toEqual({}); + expect(mapService.getDirections).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/trip/domain/trip-tracking.service.ts b/src/modules/trip/domain/trip-tracking.service.ts new file mode 100644 index 0000000..4936135 --- /dev/null +++ b/src/modules/trip/domain/trip-tracking.service.ts @@ -0,0 +1,341 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { MapService } from '../../map/map.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { + AuthMessages, + BadRequestMessages, +} from '../../../common/enums/messages.enum'; +import { + MatchedRequest, + PackageStatusEnum, + TripStatusEnum, +} from '../../../../generated/prisma'; +import { UpdateTrackingDto } from '../dto/update-tracking.dto'; +import { RateTripDto } from '../dto/rate-trip.dto'; +import { isNumber } from 'class-validator'; +import { TurfService } from '../../turf/turf.service'; +import { Location } from '../../map/map.types'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; +import { PrismaTransaction } from '../../prisma/prisma.types'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { MatchedRouteSorter } from './matched-route-sorter'; + +/** + * Trip tracking, notes, rating and directions (Phase 4 Task 4). + * + * Owns `addTripNote`, `updateTracking`, `getTripTracking`, `rateTrip` and + * `getDirections`. All multi-statement writes run through `TransactionRunner` + * (no manual `.catch` — the global filter maps Prisma errors). `rateTrip` has + * no financial interplay (it only averages the transporter rating); the + * escrow interplay lives in `TripRequestService.updateRequest` (non-fatal + * creation) and `TripService.deliveryPackage` (fatal release). + */ +@Injectable() +export class TripTrackingService { + private readonly sorter: MatchedRouteSorter; + + constructor( + private prisma: PrismaService, + private mapService: MapService, + private turfService: TurfService, + private notificationService: NotificationService, + private runner: TransactionRunner, + ) { + this.sorter = new MatchedRouteSorter(turfService); + } + + async addTripNote( + tripId: string, + note: string, + packageId?: string, + tx: PrismaTransaction = this.prisma, + ) { + const trip = await tx.trip.findUniqueOrThrow({ + where: { + id: tripId, + }, + include: { + matchedRequests: { + where: { + packageId, + }, + include: { + package: { + select: { + code: true, + senderId: true, + }, + }, + }, + }, + transporter: { + select: { + user: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }); + + if (trip.status === TripStatusEnum.completed) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, + ); + } + + // If packageId included, the note will send for all matched requests within a trip. + const updatedMatchedRequestsPromises = trip.matchedRequests.map( + async (m) => { + // Push note + const oldNotes = + plainToInstance(Array, m.transporterNotes) ?? []; + oldNotes.push(note); + const plainNewNotes = instanceToPlain(oldNotes); + + // Add create package notification + await this.notificationService.create( + m.package.senderId, + { + packageId: m.packageId, + tripId: m.tripId, + content: getNotificationMessage( + NotificationMessages.NewTransporterNote, + { + packageCode: m.package.code, + tripCode: trip.code, + noteContent: note, + }, + ), + }, + tx, + ); + + return tx.matchedRequest.update({ + where: { + tripId, + packageId: m.packageId, + }, + data: { + transporterNotes: plainNewNotes, + }, + }); + }, + ); + + // Run queries parallel and Get 'resolved' promises's length + const results = await Promise.allSettled(updatedMatchedRequestsPromises); + const count = results.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' && result.value !== null, + ).length; + + return { + count, + }; + } + + async updateTracking( + tripId: string, + trackingDto: UpdateTrackingDto, + tx: PrismaTransaction = this.prisma, + ) { + const trip = await tx.trip.findUniqueOrThrow({ + where: { id: tripId }, + select: { + status: true, + matchedRequests: true, + }, + }); + + const trackingUpdates = trip.matchedRequests.map((m) => ({ + matchedRequestId: m.id, + ...trackingDto, + })); + return tx.trackingUpdate.createMany({ + data: trackingUpdates, + }); + } + + async getTripTracking(tripId: string, packageId: string) { + return this.prisma.trackingUpdate.findMany({ + where: { + matchedRequest: { + tripId, + packageId, + }, + deletedAt: null, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async rateTrip( + userId: string, + { tripId, packageId, rate, comment }: RateTripDto, + ) { + const { + senderRating, + package: { senderId, status }, + trip: { + transporterId, + transporter: { rate: tRate, rateCount }, + }, + } = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + tripId, + packageId, + }, + select: { + senderRating: true, + package: { + select: { + senderId: true, + status: true, + }, + }, + trip: { + select: { + transporterId: true, + transporter: { + select: { + rate: true, + rateCount: true, + }, + }, + }, + }, + }, + }); + + if (senderId !== userId) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} package.`, + ); + } + + if (isNumber(senderRating)) { + throw new BadRequestException(BadRequestMessages.AlreadyRatedTrip); + } + + const isValidStatus = + status === PackageStatusEnum.delivered || + status === PackageStatusEnum.returned; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${status}*.`, + ); + } + + return this.runner.run(async (tx) => { + // Update transporter rate + const newRateCount = rateCount + 1; + const newRate = (tRate * rateCount + rate) / newRateCount; + + await tx.transporter.update({ + where: { + id: transporterId, + }, + data: { + rate: newRate, + rateCount: newRateCount, + }, + }); + + return tx.matchedRequest.update({ + where: { + tripId, + packageId, + }, + data: { + senderRating: rate, + senderComment: comment, + }, + }); + }); + } + + async getDirections(tripId: string, origin: Location) { + const { destination } = await this.prisma.trip.findFirstOrThrow({ + where: { id: tripId }, + select: { + destination: true, + }, + }); + + let matchedRequests = await this.prisma.matchedRequest.findMany({ + where: { + tripId, + }, + select: { + package: { + select: { + id: true, + status: true, + originAddress: true, + recipient: { + select: { + address: true, + }, + }, + pickupAtOrigin: true, + deliveryAtDestination: true, + }, + }, + }, + }); + + if (matchedRequests.length < 1) { + return {}; + } + + matchedRequests = await this.sorter.sort( + origin, + destination, + matchedRequests, + ); + + const waypoints = matchedRequests.flatMap((m) => + [ + m.package.pickupAtOrigin + ? { + latitude: m.package.originAddress.latitude, + longitude: m.package.originAddress.longitude, + } + : undefined, + m.package.deliveryAtDestination + ? { + latitude: m.package.recipient.address.latitude, + longitude: m.package.recipient.address.longitude, + } + : undefined, + ].filter((v) => v !== undefined), + ); + + const sortedWaypoints = this.turfService.sortLocationsByRoute( + origin, + destination, + waypoints, + ); + + return this.mapService.getDirections({ + origin, + destination, + waypoints: sortedWaypoints, + }); + } +} diff --git a/src/modules/trip/trip.controller.spec.ts b/src/modules/trip/trip.controller.spec.ts index dd9dbbd..46c063b 100644 --- a/src/modules/trip/trip.controller.spec.ts +++ b/src/modules/trip/trip.controller.spec.ts @@ -1,20 +1,30 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TripController } from './trip.controller'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { TripService } from './trip.service'; +import { TripService } from './application/trip.service'; +import { TripRequestService } from './domain/trip-request.service'; +import { TripTrackingService } from './domain/trip-tracking.service'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { OwnershipGuard } from '../auth/guard/ownership.guard'; describe('TripController', () => { let controller: TripController; let service: DeepMockProxy; + let requestService: DeepMockProxy; + let trackingService: DeepMockProxy; beforeEach(async () => { service = mockDeep(); + requestService = mockDeep(); + trackingService = mockDeep(); const module: TestingModule = await Test.createTestingModule({ controllers: [TripController], - providers: [{ provide: TripService, useValue: service }], + providers: [ + { provide: TripService, useValue: service }, + { provide: TripRequestService, useValue: requestService }, + { provide: TripTrackingService, useValue: trackingService }, + ], }) .overrideGuard(AccessTokenGuard) .useValue({ canActivate: () => true }) @@ -28,4 +38,29 @@ describe('TripController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should delegate request routes to TripRequestService', async () => { + await controller.getAllTripRequests('trip-123'); + expect(requestService.getAllTripRequests).toHaveBeenCalledWith('trip-123'); + + await controller.updateTripRequest('request-123', { + status: 'rejected', + } as any); + expect(requestService.updateRequest).toHaveBeenCalledWith('request-123', { + status: 'rejected', + }); + }); + + it('should delegate tracking routes to TripTrackingService', async () => { + await controller.getTripTracking('trip-123', 'package-123'); + expect(trackingService.getTripTracking).toHaveBeenCalledWith( + 'trip-123', + 'package-123', + ); + + await controller.rateTrip({ tripId: 'trip-123' } as any, 'user-123'); + expect(trackingService.rateTrip).toHaveBeenCalledWith('user-123', { + tripId: 'trip-123', + }); + }); }); diff --git a/src/modules/trip/trip.controller.ts b/src/modules/trip/trip.controller.ts index a1835d7..506be9a 100644 --- a/src/modules/trip/trip.controller.ts +++ b/src/modules/trip/trip.controller.ts @@ -13,7 +13,9 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { TripService } from './trip.service'; +import { TripService } from './application/trip.service'; +import { TripRequestService } from './domain/trip-request.service'; +import { TripTrackingService } from './domain/trip-tracking.service'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { CreateTripDto } from './dto/create-trip.dto'; import { @@ -49,7 +51,11 @@ import { CoordinateQueryDto } from '../map/coordinates-query.dto'; @Controller('trips') export class TripController { - constructor(private tripService: TripService) {} + constructor( + private tripService: TripService, + private requestService: TripRequestService, + private trackingService: TripTrackingService, + ) {} @ApiOperation({ summary: 'Create a new trip', @@ -168,7 +174,7 @@ export class TripController { }) @Get(':id/requests') async getAllTripRequests(@Param('id', ParseUUIDPipe) id: string) { - return this.tripService.getAllTripRequests(id); + return this.requestService.getAllTripRequests(id); } @ApiOperation({ @@ -186,7 +192,7 @@ export class TripController { @Param('id', ParseUUIDPipe) id: string, @Body() body: UpdateRequestDto, ) { - return this.tripService.updateRequest(id, body); + return this.requestService.updateRequest(id, body); } @ApiOperation({ @@ -211,7 +217,7 @@ export class TripController { @Query('inOrder', new ParseBoolPipe({ optional: true })) inOrder: boolean = false, ) { - return this.tripService.getAllMatchedRequests(id, inOrder); + return this.requestService.getAllMatchedRequests(id, inOrder); } @ApiOperation({ @@ -356,7 +362,7 @@ export class TripController { @Param('id', ParseUUIDPipe) tripId: string, @Body() body: AddNoteDto, ) { - return this.tripService.addTripNote(tripId, body.note, body.packageId); + return this.trackingService.addTripNote(tripId, body.note, body.packageId); } @ApiOperation({ @@ -373,7 +379,7 @@ export class TripController { @Param('id', ParseUUIDPipe) tripId: string, @Body() body: BroadcastNoteDto, ) { - return this.tripService.addTripNote(tripId, body.note); + return this.trackingService.addTripNote(tripId, body.note); } @ApiOperation({ @@ -392,7 +398,7 @@ export class TripController { @Param('id', ParseUUIDPipe) tripId: string, @Body() body: UpdateTrackingDto, ) { - return this.tripService.updateTracking(tripId, body); + return this.trackingService.updateTracking(tripId, body); } @ApiOperation({ @@ -414,7 +420,7 @@ export class TripController { @Param('tripId', ParseUUIDPipe) tripId: string, @Param('packageId', ParseUUIDPipe) packageId: string, ) { - return this.tripService.getTripTracking(tripId, packageId); + return this.trackingService.getTripTracking(tripId, packageId); } @ApiOperation({ @@ -431,7 +437,7 @@ export class TripController { @HttpCode(HttpStatus.OK) @Post('rate') async rateTrip(@Body() body: RateTripDto, @CurrentUser('id') userId: string) { - return this.tripService.rateTrip(userId, body); + return this.trackingService.rateTrip(userId, body); } @ApiOperation({ @@ -451,7 +457,7 @@ export class TripController { @Param('id', ParseUUIDPipe) id: string, @Query() origin: CoordinateQueryDto, ) { - return this.tripService.getDirections(id, { + return this.trackingService.getDirections(id, { latitude: origin.lat, longitude: origin.lng, }); diff --git a/src/modules/trip/trip.module.ts b/src/modules/trip/trip.module.ts index 3df7137..b731c2b 100644 --- a/src/modules/trip/trip.module.ts +++ b/src/modules/trip/trip.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TripController } from './trip.controller'; -import { TripService } from './trip.service'; +import { TripService } from './application/trip.service'; +import { TripRequestService } from './domain/trip-request.service'; +import { TripTrackingService } from './domain/trip-tracking.service'; import { MapModule } from '../map/map.module'; import { PrismaModule } from '../prisma/prisma.module'; import { TokenModule } from '../token/token.module'; @@ -22,7 +24,7 @@ import { NotificationModule } from '../notification/notification.module'; NotificationModule, ], controllers: [TripController], - providers: [TripService], - exports: [TripService], + providers: [TripService, TripRequestService, TripTrackingService], + exports: [TripService, TripRequestService, TripTrackingService], }) export class TripModule {} diff --git a/src/modules/trip/trip.service.spec.ts b/src/modules/trip/trip.service.spec.ts deleted file mode 100644 index 9072a2f..0000000 --- a/src/modules/trip/trip.service.spec.ts +++ /dev/null @@ -1,1523 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TripService } from './trip.service'; -import { S3Service } from '../s3/s3.service'; -import { FinancialService } from '../financial/financial.service'; -import { MapService } from '../map/map.service'; -import { - PrismaClient, - TripStatusEnum, - TripTypeEnum, - RequestStatusEnum, - PackageStatusEnum, -} from '../../../generated/prisma'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { PrismaService } from '../prisma/prisma.service'; -import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { - BadRequestMessages, - AuthMessages, - TrackingMessages, -} from '../../common/enums/messages.enum'; -import * as utilities from '../../common/utilities'; -import { TurfService } from '../turf/turf.service'; -import { NotificationService } from '../notification/notification.service'; - -jest.mock('../../common/utilities', () => ({ - generateCode: jest.fn(() => 12345), - generateUniqueCode: jest.fn(() => '17571445988911932924'), - formatPrismaError: jest.fn(), -})); - -describe('TripService', () => { - let service: TripService; - let prisma: DeepMockProxy; - let mapService: DeepMockProxy; - let financialService: DeepMockProxy; - let s3Service: DeepMockProxy; - let turfService: DeepMockProxy; - let notificationService: DeepMockProxy; - - const mockVehicle = { - id: 'vehicle-123', - ownerId: 'transporter-123', - owner: { - userId: 'user-123', - }, - } as any; - - const mockCity = { - id: 'city-123', - name: 'Tehran', - latitude: '35.6892', - longitude: '51.3890', - } as any; - - const mockTrip = { - id: 'trip-123', - transporterId: 'transporter-123', - originId: 'city-origin', - destinationId: 'city-dest', - vehicleId: 'vehicle-123', - status: TripStatusEnum.scheduled, - origin: mockCity, - destination: mockCity, - waypoints: [], - vehicle: mockVehicle, - normalDistanceKm: 100, - normalDurationMin: 120, - totalDeviationDistanceKm: 0, - totalDeviationDurationMin: 0, - matchedRequests: [], - } as any; - - const mockCreateTripDto = { - originId: 'city-origin', - destinationId: 'city-dest', - vehicleId: 'vehicle-123', - departureTime: [new Date(), new Date(Date.now() + 3600000)] as [Date, Date], - maxPackageWeightGr: 5000, - restrictedItems: ['fragile'], - description: 'Test trip', - }; - - const mockTripRequest = { - id: 'request-123', - tripId: 'trip-123', - packageId: 'package-123', - status: RequestStatusEnum.pending, - deviationDistanceKm: 10, - deviationDurationMin: 15, - deviationCost: 50000, - } as any; - - const mockMatchedRequest = { - id: 'matched-123', - tripId: 'trip-123', - packageId: 'package-123', - trackingCode: '17571445988911932924', - deliveryCode: '12345', - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - package: { - id: 'package-123', - status: PackageStatusEnum.matched, - senderId: 'user-123', - originAddress: { - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - }, - recipient: { - address: { - latitude: '35.7219', - longitude: '51.3347', - city: 'Tehran', - }, - }, - breakdown: { baseCost: 100000, deviationCost: 0 }, - }, - trip: { - status: TripStatusEnum.in_progress, - transporter: { - rate: 4.5, - rateCount: 10, - profilePictureKey: 'profile-pic-key', - user: { - firstName: 'Ahmad', - lastName: 'Mohammadi', - phoneNumber: '+989123456789', - }, - }, - vehicle: { - vehicleType: 'truck', - model: { brand: { name: 'Volvo' } }, - }, - }, - } as any; - - const trackingDto = { - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - routeName: 'Azadi Square', - description: 'Moving towards destination', - }; - - beforeEach(async () => { - jest.resetAllMocks(); - - prisma = mockDeep(); - mapService = mockDeep(); - financialService = mockDeep(); - s3Service = mockDeep(); - turfService = mockDeep(); - notificationService = mockDeep(); - - // Reset utility mocks to default values - (utilities.generateCode as jest.Mock).mockReturnValue(12345); - (utilities.generateUniqueCode as jest.Mock).mockReturnValue( - '17571445988911932924', - ); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TripService, - { provide: PrismaService, useValue: prisma }, - { provide: MapService, useValue: mapService }, - { provide: FinancialService, useValue: financialService }, - { provide: S3Service, useValue: s3Service }, - { provide: TurfService, useValue: turfService }, - { provide: NotificationService, useValue: notificationService }, - ], - }).compile(); - - service = module.get(TripService); - }); - - describe('create', () => { - it('should create trip successfully', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); - prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); - mapService.calculateDistance.mockResolvedValue({ - distance: 100, - duration: 120, - }); - prisma.trip.create.mockResolvedValue(mockTrip); - - const result = await service.create('user-123', mockCreateTripDto); - - expect(result).toEqual(mockTrip); - expect(prisma.vehicle.findFirst).toHaveBeenCalledWith({ - where: { id: 'vehicle-123', owner: { userId: 'user-123' } }, - }); - expect(mapService.calculateDistance).toHaveBeenCalled(); - expect(prisma.trip.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - transporterId: 'transporter-123', - originId: 'city-origin', - destinationId: 'city-dest', - vehicleId: 'vehicle-123', - tripType: TripTypeEnum.intercity, - normalDistanceKm: 100, - normalDurationMin: 120, - }), - }); - }); - - it('should create trip with waypoints', async () => { - const tripDtoWithWaypoints = { - ...mockCreateTripDto, - waypoints: [{ id: 'way-1', name: 'Waypoint 1' }], - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); - prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); - mapService.calculateDistance.mockResolvedValue({ - distance: 100, - duration: 120, - }); - prisma.trip.create.mockResolvedValue(mockTrip); - - await service.create('user-123', tripDtoWithWaypoints as any); - - expect(prisma.trip.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - waypoints: { - createMany: { - data: [{ id: 'way-1', name: 'Waypoint 1' }], - }, - }, - }), - }); - }); - - it('should throw when vehicle not found', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(null); - - await expect( - service.create('user-123', mockCreateTripDto), - ).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} vehicle.`), - ); - }); - }); - - describe('getById', () => { - it('should get trip by id successfully', async () => { - const tripWithIncludes = { - ...mockTrip, - waypoints: [{ id: 'way-1', isVisible: true }], - }; - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithIncludes); - - const result = await service.getById('trip-123'); - - expect(result).toEqual(tripWithIncludes); - expect(prisma.trip.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - include: { - origin: true, - destination: true, - waypoints: { where: { isVisible: true } }, - vehicle: { - select: { - vehicleType: true, - model: { include: { brand: true } }, - manufactureYear: true, - color: true, - }, - }, - }, - }); - }); - }); - - describe('getAll', () => { - it('should get all trips with default status filter', async () => { - const trips = [mockTrip]; - prisma.trip.findMany.mockResolvedValue(trips); - - const result = await service.getAll('user-123'); - - expect(result).toEqual(trips); - expect(prisma.trip.findMany).toHaveBeenCalledWith({ - where: { - transporter: { userId: 'user-123' }, - status: { - in: [ - TripStatusEnum.scheduled, - TripStatusEnum.closed, - TripStatusEnum.delayed, - TripStatusEnum.in_progress, - ], - }, - deletedAt: null, - }, - include: { - origin: true, - destination: true, - waypoints: { where: { isVisible: true } }, - }, - orderBy: { createdAt: 'desc' }, - }); - }); - - it('should get all trips with custom status filter', async () => { - const trips = [mockTrip]; - prisma.trip.findMany.mockResolvedValue(trips); - - await service.getAll('user-123', [TripStatusEnum.scheduled]); - - expect(prisma.trip.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - status: { in: [TripStatusEnum.scheduled] }, - }), - }), - ); - }); - }); - - describe('update', () => { - const updateDto = { - maxPackageWeightGr: 6000, - description: 'Updated description', - }; - - it('should update trip successfully', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.trip.update.mockResolvedValue({ ...mockTrip, ...updateDto }); - - const result = await service.update('trip-123', updateDto); - - expect(result).toEqual({ ...mockTrip, ...updateDto }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: updateDto, - }); - }); - - it('should update trip with waypoints', async () => { - const updateDtoWithWaypoints = { - ...updateDto, - waypoints: [{ id: 'way-2', name: 'New waypoint' }], - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.tripWaypoint.deleteMany.mockResolvedValue({ count: 1 }); - prisma.trip.update.mockResolvedValue(mockTrip); - - await service.update('trip-123', updateDtoWithWaypoints as any); - - expect(prisma.tripWaypoint.deleteMany).toHaveBeenCalledWith({ - where: { tripId: 'trip-123', isVisible: true }, - }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: expect.objectContaining({ - waypoints: { - createMany: { - data: [{ id: 'way-2', name: 'New waypoint' }], - }, - }, - }), - }); - }); - - it('should throw when trip status is not scheduled', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - } as any); - - await expect(service.update('trip-123', updateDto)).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.in_progress}*.`, - ), - ); - }); - }); - - describe('delete', () => { - it('should delete trip successfully', async () => { - const deletedTrip = { ...mockTrip, deletedAt: new Date() }; - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.trip.update.mockResolvedValue(deletedTrip); - - const result = await service.delete('trip-123'); - - expect(result).toEqual(deletedTrip); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { deletedAt: expect.any(Date) }, - }); - }); - - it('should throw when trip status is not scheduled', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.completed, - } as any); - - await expect(service.delete('trip-123')).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, - ), - ); - }); - }); - - describe('getAllTripRequests', () => { - it('should get all pending trip requests', async () => { - const requests = [mockTripRequest]; - prisma.tripRequest.findMany.mockResolvedValue(requests); - - const result = await service.getAllTripRequests('trip-123'); - - expect(result).toEqual(requests); - expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ - where: { tripId: 'trip-123', status: RequestStatusEnum.pending }, - orderBy: { createdAt: 'asc' }, - }); - }); - }); - - describe('updateRequest', () => { - it('should reject request', async () => { - const updatedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.rejected, - }; - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue({ - ...updatedRequest, - package: { code: 'PKG-123', senderId: 'user-123' }, - trip: { code: 'TRIP-123' }, - }); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.rejected, - } as any); - - expect(result).toEqual(updatedRequest); - expect(prisma.tripRequest.update).toHaveBeenCalledWith({ - where: { id: 'request-123' }, - data: { status: RequestStatusEnum.rejected }, - include: { - package: { - select: { - code: true, - senderId: true, - }, - }, - trip: { - select: { - code: true, - }, - }, - }, - }); - }); - - it('should accept request and create matched request', async () => { - const acceptedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.accepted, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue(acceptedRequest); - prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); - prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - totalDeviationDistanceKm: 0, - totalDeviationDurationMin: 0, - } as any); - prisma.trip.update.mockResolvedValue(mockTrip); - prisma.package.findFirstOrThrow.mockResolvedValue({ - breakdown: { baseCost: 100000, deviationCost: 0 }, - senderId: 'user-123', - } as any); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - financialService.createEscrow.mockResolvedValue({} as any); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.accepted, - transporterNotes: ['Note 1'], - } as any); - - expect(result).toEqual(acceptedRequest); - expect(prisma.matchedRequest.create).toHaveBeenCalledWith({ - data: { - requestId: 'request-123', - packageId: 'package-123', - tripId: 'trip-123', - trackingCode: mockMatchedRequest.trackingCode, - deliveryCode: mockMatchedRequest.deliveryCode, - transporterNotes: ['Note 1'], - }, - }); - expect(prisma.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123' }, - data: { - status: PackageStatusEnum.matched, - finalPrice: { increment: 50000 }, - breakdown: expect.any(Object), - }, - }); - }); - }); - - describe('toggleTripAccess', () => { - it('should toggle from scheduled to closed', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.closed, - }); - - const result = await service.toggleTripAccess('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.closed }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { status: TripStatusEnum.closed }, - }); - }); - - it('should toggle from closed to scheduled', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.closed, - } as any); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.scheduled, - }); - - const result = await service.toggleTripAccess('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.scheduled }); - }); - - it('should throw when trip status is invalid', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.completed, - } as any); - - await expect(service.toggleTripAccess('trip-123')).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, - ), - ); - }); - }); - - describe('startTrip', () => { - it('should start trip successfully', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.scheduled, - origin: mockCity, - } as any); - - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.scheduled, - matchedRequests: [{ id: 'matched-1' }, { id: 'matched-2' }], - } as any); - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.update.mockResolvedValueOnce({ - ...mockTrip, - status: TripStatusEnum.in_progress, - }); - prisma.trackingUpdate.createMany.mockResolvedValue({ count: 2 }); - - const result = await service.startTrip('trip-123'); - - expect(result).toEqual({ - ...mockTrip, - status: TripStatusEnum.in_progress, - }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { status: TripStatusEnum.in_progress }, - }); - expect(prisma.trackingUpdate.createMany).toHaveBeenCalledWith({ - data: [ - { - matchedRequestId: 'matched-1', - city: 'Tehran', - description: TrackingMessages.TripStarted, - }, - { - matchedRequestId: 'matched-2', - city: 'Tehran', - description: TrackingMessages.TripStarted, - }, - ], - }); - }); - }); - - describe('pickupPackage', () => { - it('should pickup package successfully', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( - mockMatchedRequest, - ); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.in_transit, - } as any); - prisma.matchedRequest.update.mockResolvedValue({ - pickupTime: new Date(), - } as any); - prisma.trackingUpdate.create.mockResolvedValue({} as any); - - const result = await service.pickupPackage('trip-123', 'package-123'); - - expect(result.packageStatus).toBe(PackageStatusEnum.in_transit); - expect(result.pickupTime).toBeInstanceOf(Date); - expect(prisma.trackingUpdate.create).toHaveBeenCalledWith({ - data: { - matchedRequestId: 'matched-123', - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - description: TrackingMessages.PackagePickedUp, - }, - }); - }); - - it('should throw when package status is invalid', async () => { - const invalidMatchedRequest = { - ...mockMatchedRequest, - package: { - ...mockMatchedRequest.package, - status: PackageStatusEnum.delivered, - }, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( - invalidMatchedRequest, - ); - - await expect( - service.pickupPackage('trip-123', 'package-123'), - ).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.delivered}*.`, - ), - ); - }); - - it('should throw when trip status is invalid', async () => { - const invalidMatchedRequest = { - ...mockMatchedRequest, - trip: { status: TripStatusEnum.completed }, - }; - - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( - invalidMatchedRequest, - ); - - await expect( - service.pickupPackage('trip-123', 'package-123'), - ).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${PackageStatusEnum.matched}*.`, - ), - ); - }); - }); - - describe('deliveryPackage', () => { - const inTransitMatchedRequest = { - ...mockMatchedRequest, - package: { - ...mockMatchedRequest.package, - status: PackageStatusEnum.in_transit, - }, - }; - - it('should deliver package successfully', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - inTransitMatchedRequest, - ); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.delivered, - } as any); - prisma.matchedRequest.update.mockResolvedValue({ - deliveryTime: new Date(), - } as any); - prisma.trackingUpdate.create.mockResolvedValue({} as any); - financialService.releaseEscrow.mockResolvedValue({} as any); - - const result = await service.deliveryPackage( - 'trip-123', - 'package-123', - '12345', - ); - - expect(result.packageStatus).toBe(PackageStatusEnum.delivered); - expect(result.deliveryTime).toBeInstanceOf(Date); - expect(financialService.releaseEscrow).toHaveBeenCalledWith( - 'package-123', - 'trip-123', - prisma, - ); - }); - - it('should throw when delivery code is wrong', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - inTransitMatchedRequest, - ); - - await expect( - service.deliveryPackage('trip-123', 'package-123', '54321'), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.WrongDeliveryCode), - ); - }); - }); - - describe('finishTrip', () => { - it('should finish trip successfully', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - matchedRequests: [{ deliveryTime: new Date() }], - transporter: { - id: 'transporter-123', - firstTripDate: null, - }, - } as any); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.completed, - }); - prisma.transporter.update.mockResolvedValue({} as any); - - const result = await service.finishTrip('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); - expect(prisma.transporter.update).toHaveBeenCalledWith({ - where: { id: 'transporter-123' }, - data: { firstTripDate: expect.any(Date) }, - }); - }); - - it('should finish trip successfully when transporter has firstTripDate', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - matchedRequests: [{ deliveryTime: new Date() }], - transporter: { - id: 'transporter-123', - firstTripDate: new Date('2023-01-01'), - }, - } as any); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.completed, - }); - prisma.transporter.update.mockResolvedValue({} as any); - - const result = await service.finishTrip('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); - expect(prisma.transporter.update).toHaveBeenCalledWith({ - where: { id: 'transporter-123' }, - data: { lastTripDate: expect.any(Date) }, - }); - }); - - it('should throw when not all packages are delivered', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - matchedRequests: [{ deliveryTime: null }], - transporter: { - id: 'transporter-123', - firstTripDate: null, - }, - } as any); - - await expect(service.finishTrip('trip-123')).rejects.toThrow( - new BadRequestException(BadRequestMessages.CannotFinishTrip), - ); - }); - }); - - describe('addTripNote', () => { - it('should add note to specific package', async () => { - const tripWithMatches = { - ...mockTrip, - matchedRequests: [ - { - packageId: 'package-123', - transporterNotes: [], - package: { senderId: 'user-123' }, - }, - ], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); - prisma.matchedRequest.update.mockResolvedValue({} as any); - - const result = await service.addTripNote( - 'trip-123', - 'Test note', - 'package-123', - ); - - expect(result.count).toBe(1); - expect(prisma.matchedRequest.update).toHaveBeenCalledWith({ - where: { tripId: 'trip-123', packageId: 'package-123' }, - data: { transporterNotes: ['Test note'] }, - }); - }); - - it('should throw when trip is completed', async () => { - const completedTrip = { - ...mockTrip, - status: TripStatusEnum.completed, - matchedRequests: [], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(completedTrip); - - await expect( - service.addTripNote('trip-123', 'Test note'), - ).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, - ), - ); - }); - }); - - describe('getTripTracking', () => { - it('should get trip tracking successfully', async () => { - const trackingUpdates = [ - { id: 'tracking-1', city: 'Tehran', createdAt: new Date() }, - ]; - prisma.trackingUpdate.findMany.mockResolvedValue(trackingUpdates as any); - - const result = await service.getTripTracking('trip-123', 'package-123'); - - expect(result).toEqual(trackingUpdates); - expect(prisma.trackingUpdate.findMany).toHaveBeenCalledWith({ - where: { - matchedRequest: { tripId: 'trip-123', packageId: 'package-123' }, - deletedAt: null, - }, - orderBy: { createdAt: 'desc' }, - }); - }); - }); - - describe('rateTrip', () => { - const rateDto = { - tripId: 'trip-123', - packageId: 'package-123', - rate: 5, - comment: 'Great service!', - }; - - const mockRatingData = { - senderRating: null, - package: { - senderId: 'user-123', - status: PackageStatusEnum.delivered, - }, - trip: { - transporterId: 'transporter-123', - transporter: { - rate: 4.5, - rateCount: 10, - }, - }, - }; - - it('should rate trip successfully', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - mockRatingData as any, - ); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.transporter.update.mockResolvedValue({} as any); - prisma.matchedRequest.update.mockResolvedValue({ - senderRating: 5, - } as any); - - const result = await service.rateTrip('user-123', rateDto); - - expect(result.senderRating).toBe(5); - expect(prisma.transporter.update).toHaveBeenCalledWith({ - where: { id: 'transporter-123' }, - data: { - rate: (4.5 * 10 + 5) / 11, // New average - rateCount: 11, - }, - }); - }); - - it('should throw when user is not the sender', async () => { - const wrongUserRatingData = { - ...mockRatingData, - package: { ...mockRatingData.package, senderId: 'different-user' }, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - wrongUserRatingData as any, - ); - - await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), - ); - }); - - it('should throw when trip already rated', async () => { - const alreadyRatedData = { - ...mockRatingData, - senderRating: 4, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - alreadyRatedData as any, - ); - - await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( - new BadRequestException(BadRequestMessages.AlreadyRatedTrip), - ); - }); - - it('should throw when package status is invalid', async () => { - const invalidStatusData = { - ...mockRatingData, - package: { - ...mockRatingData.package, - status: PackageStatusEnum.matched, - }, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - invalidStatusData as any, - ); - - await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.matched}*.`, - ), - ); - }); - }); - - describe('updateTracking', () => { - it('should update tracking successfully', async () => { - const tripWithMatches = { - status: TripStatusEnum.in_progress, - matchedRequests: [{ id: 'matched-1' }, { id: 'matched-2' }], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce( - tripWithMatches as any, - ); - prisma.trackingUpdate.createMany.mockResolvedValue({ count: 2 }); - - const result = await service.updateTracking('trip-123', trackingDto); - - expect(result.count).toBe(2); - expect(prisma.trackingUpdate.createMany).toHaveBeenCalledWith({ - data: [ - { matchedRequestId: 'matched-1', ...trackingDto }, - { matchedRequestId: 'matched-2', ...trackingDto }, - ], - }); - }); - }); - - describe('getMultipleById', () => { - it('should get multiple trips by ids', async () => { - const trips = [mockTrip]; - prisma.trip.findMany.mockResolvedValue(trips); - - const result = await service.getMultipleById(['trip-123', 'trip-456']); - - expect(result).toEqual(trips); - expect(prisma.trip.findMany).toHaveBeenCalledWith({ - where: { - id: { in: ['trip-123', 'trip-456'] }, - status: TripStatusEnum.scheduled, - }, - include: { - origin: true, - destination: true, - waypoints: true, - vehicle: { - include: { - model: { include: { brand: true } }, - }, - }, - matchedRequests: { - include: { - package: { - include: { - originAddress: true, - recipient: { include: { address: true } }, - }, - }, - }, - }, - }, - }); - }); - }); - - describe('getAllMatchedRequests', () => { - it('should get all matched requests for a trip', async () => { - const matchedRequests = [ - { - package: { - id: 'package-123', - sender: { firstName: 'Ahmad', lastName: 'Mohammadi' }, - items: ['Electronics'], - weight: 2.5, - picturesKey: ['key-1'], - }, - request: { offeredPrice: 75000 }, - transporterNotes: ['Handle with care'], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - }, - ]; - - prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); - s3Service.generateGetPresignedUrl.mockResolvedValue( - 'https://s3.example.com/key-1', - ); - - const result = await service.getAllMatchedRequests('trip-123'); - - expect(result).toEqual([ - { - package: { - ...matchedRequests[0].package, - picturesUrl: ['https://s3.example.com/key-1'], - offeredPrice: 75000, - picturesKey: undefined, - }, - transporterNotes: ['Handle with care'], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - request: undefined, - }, - ]); - expect(prisma.matchedRequest.findMany).toHaveBeenCalledWith({ - where: { tripId: 'trip-123' }, - orderBy: { updatedAt: 'desc' }, - select: { - package: { - select: { - id: true, - code: true, - sender: { - select: { - firstName: true, - lastName: true, - gender: true, - phoneNumber: true, - }, - }, - status: true, - items: true, - originAddress: true, - recipient: { select: { address: true } }, - weight: true, - dimensions: true, - packageValue: true, - isFragile: true, - isPerishable: true, - description: true, - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: true, - preferredDeliveryTime: true, - picturesKey: true, - }, - }, - request: true, - transporterNotes: true, - pickupTime: true, - deliveryTime: true, - paymentStatus: true, - }, - }); - }); - - it('should return matched requests in route order when inOrder is true', async () => { - const matchedRequests = [ - { - package: { - id: 'package-1', - code: 'PKG-1', - sender: { - firstName: 'A', - lastName: 'A', - gender: 'male', - phoneNumber: '+1', - }, - status: PackageStatusEnum.matched, - items: ['Item1'], - originAddress: { - latitude: '35.7', - longitude: '51.4', - city: 'Tehran', - }, - recipient: { - address: { latitude: '35.8', longitude: '51.5', city: 'Tehran' }, - }, - weight: 1, - dimensions: { w: 1, h: 1, l: 1 } as any, - packageValue: 100, - isFragile: false, - isPerishable: false, - description: 'desc1', - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: null, - preferredDeliveryTime: null, - picturesKey: ['k1'], - }, - request: { offeredPrice: 10000 }, - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - }, - { - package: { - id: 'package-2', - code: 'PKG-2', - sender: { - firstName: 'B', - lastName: 'B', - gender: 'female', - phoneNumber: '+2', - }, - status: PackageStatusEnum.matched, - items: ['Item2'], - originAddress: { - latitude: '35.6', - longitude: '51.3', - city: 'Tehran', - }, - recipient: { - address: { latitude: '35.9', longitude: '51.6', city: 'Tehran' }, - }, - weight: 2, - dimensions: { w: 2, h: 2, l: 2 } as any, - packageValue: 200, - isFragile: true, - isPerishable: false, - description: 'desc2', - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: null, - preferredDeliveryTime: null, - picturesKey: ['k2'], - }, - request: { offeredPrice: 20000 }, - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - }, - ]; - - prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); - prisma.trip.findFirstOrThrow.mockResolvedValue({ - origin: { latitude: '35.65', longitude: '51.35', name: 'Origin' }, - destination: { - latitude: '35.95', - longitude: '51.65', - name: 'Destination', - }, - } as any); - - // First call: sortLocationsByRoute for map of package locations -> returns Map ordering by package-2 then package-1 - (turfService.sortLocationsByRoute as any).mockImplementationOnce( - (_o, _d, _locationsMap: Map) => - new Map([ - ['package-2', { latitude: '35.6', longitude: '51.3' }], - ['package-1', { latitude: '35.7', longitude: '51.4' }], - ]), - ); - - s3Service.generateGetPresignedUrl.mockImplementation( - async (key: string) => `https://s3.example.com/${key}`, - ); - - const result = await service.getAllMatchedRequests('trip-123', true); - - expect(result.map((r) => r.package.id)).toEqual([ - 'package-2', - 'package-1', - ]); - expect(result[0].package.picturesUrl).toEqual([ - 'https://s3.example.com/k2', - ]); - expect(result[1].package.picturesUrl).toEqual([ - 'https://s3.example.com/k1', - ]); - }); - }); - - describe('getDirections', () => { - it('should build sorted waypoints and fetch directions', async () => { - const origin = { - latitude: '35.60', - longitude: '51.30', - name: 'Start', - } as any; - - prisma.trip.findFirstOrThrow.mockResolvedValue({ - destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, - } as any); - - // Two matched requests with pickup and delivery points - prisma.matchedRequest.findMany.mockResolvedValue([ - { - package: { - id: 'package-1', - status: PackageStatusEnum.matched, - originAddress: { latitude: '35.70', longitude: '51.40' }, - recipient: { address: { latitude: '35.80', longitude: '51.50' } }, - pickupAtOrigin: true, - deliveryAtDestination: true, - }, - }, - { - package: { - id: 'package-2', - status: PackageStatusEnum.matched, - originAddress: { latitude: '35.65', longitude: '51.35' }, - recipient: { address: { latitude: '35.90', longitude: '51.60' } }, - pickupAtOrigin: true, - deliveryAtDestination: true, - }, - }, - ] as any); - - // First call used by sortMatchedPackages: return an ordering map keyed by package ids - (turfService.sortLocationsByRoute as any).mockImplementationOnce( - (_o, _d, _map) => - new Map([ - ['package-2', { latitude: '35.65', longitude: '51.35' }], - ['package-1', { latitude: '35.70', longitude: '51.40' }], - ]), - ); - - // Second call sorts the final waypoints array - (turfService.sortLocationsByRoute as any).mockImplementationOnce( - (_o, _d, waypoints: any[]) => waypoints, - ); - - const mockedDirections = { polyline: 'encoded-polyline' } as any; - mapService.getDirections.mockResolvedValue(mockedDirections); - - const result = await service.getDirections('trip-123', origin); - - expect(mapService.getDirections).toHaveBeenCalledWith({ - origin, - destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, - waypoints: [ - { latitude: '35.65', longitude: '51.35' }, - { latitude: '35.90', longitude: '51.60' }, - { latitude: '35.70', longitude: '51.40' }, - { latitude: '35.80', longitude: '51.50' }, - ], - }); - expect(result).toBe(mockedDirections); - }); - }); - - describe('Error handling', () => { - it('should handle escrow creation failure gracefully', async () => { - const acceptedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.accepted, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue(acceptedRequest); - prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); - prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - totalDeviationDistanceKm: 0, - totalDeviationDurationMin: 0, - } as any); - prisma.trip.update.mockResolvedValue(mockTrip); - prisma.package.findFirstOrThrow.mockResolvedValue({ - breakdown: { baseCost: 100000, deviationCost: 0 }, - senderId: 'user-123', - } as any); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - - financialService.createEscrow.mockRejectedValue( - new Error('Insufficient balance'), - ); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.accepted, - } as any); - - expect(result).toEqual(acceptedRequest); - expect(financialService.createEscrow).toHaveBeenCalled(); - }); - }); - - describe('Edge cases', () => { - it('should handle empty waypoints array in create', async () => { - const tripDtoWithEmptyWaypoints = { - ...mockCreateTripDto, - waypoints: undefined, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); - prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); - mapService.calculateDistance.mockResolvedValue({ - distance: 100, - duration: 120, - }); - prisma.trip.create.mockResolvedValue(mockTrip); - - await service.create('user-123', tripDtoWithEmptyWaypoints); - - expect(prisma.trip.create).toHaveBeenCalledWith({ - data: expect.not.objectContaining({ - waypoints: expect.anything(), - }), - }); - }); - - it('should handle null breakdown in updateRequest', async () => { - const acceptedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.accepted, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue(acceptedRequest); - prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); - prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - totalDeviationDistanceKm: null, - totalDeviationDurationMin: null, - } as any); - prisma.trip.update.mockResolvedValue(mockTrip); - prisma.package.findFirstOrThrow.mockResolvedValue({ - breakdown: null, - senderId: 'user-123', - } as any); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - financialService.createEscrow.mockResolvedValue({} as any); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.accepted, - } as any); - - expect(result).toEqual(acceptedRequest); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { - totalDeviationDistanceKm: 10, // 0 + 10 - totalDeviationDurationMin: 15, // 0 + 15 - }, - }); - }); - - it('should handle empty matched requests in addTripNote', async () => { - const tripWithoutMatches = { - ...mockTrip, - matchedRequests: [], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithoutMatches); - - const result = await service.addTripNote('trip-123', 'Test note'); - - expect(result.count).toBe(0); - }); - - it('should handle partial delivery failures in getAllMatchedRequests', async () => { - jest.spyOn(Promise, 'allSettled').mockResolvedValue([ - { status: 'fulfilled', value: { id: 'matched-1' } as any }, - { status: 'rejected', reason: new Error('Database error') }, - { status: 'fulfilled', value: { id: 'matched-3' } as any }, - ]); - - const tripWithMatches = { - ...mockTrip, - matchedRequests: [ - { - packageId: 'package-1', - transporterNotes: [], - package: { senderId: 'user-1' }, - }, - { - packageId: 'package-2', - transporterNotes: [], - package: { senderId: 'user-2' }, - }, - { - packageId: 'package-3', - transporterNotes: [], - package: { senderId: 'user-3' }, - }, - ], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); - - const result = await service.addTripNote('trip-123', 'Broadcast note'); - - expect(result.count).toBe(2); - - jest.restoreAllMocks(); - }); - }); - - describe('Private method tests', () => { - it('should update package status correctly', async () => { - const updatedPackage = { - id: 'package-123', - status: PackageStatusEnum.in_transit, - }; - prisma.package.update.mockResolvedValue(updatedPackage as any); - - const result = await service['updatePackageStatus']( - 'package-123', - PackageStatusEnum.in_transit, - ); - - expect(result).toEqual(updatedPackage); - expect(prisma.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123', deletedAt: null }, - data: { status: PackageStatusEnum.in_transit }, - }); - }); - - it('should update trip status correctly', async () => { - const updatedTrip = { ...mockTrip, status: TripStatusEnum.completed }; - prisma.trip.update.mockResolvedValue(updatedTrip); - - const result = await service['updateStatus']( - 'trip-123', - TripStatusEnum.completed, - ); - - expect(result).toEqual(updatedTrip); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { status: TripStatusEnum.completed }, - }); - }); - }); -}); diff --git a/src/modules/trip/trip.service.ts b/src/modules/trip/trip.service.ts deleted file mode 100644 index 2556ae3..0000000 --- a/src/modules/trip/trip.service.ts +++ /dev/null @@ -1,1358 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, -} from '@nestjs/common'; -import { MapService } from '../map/map.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { - formatPrismaError, - generateCode, - generateUniqueCode, -} from '../../common/utilities'; -import { CreateTripDto } from './dto/create-trip.dto'; -import { - AuthMessages, - BadRequestMessages, - TrackingMessages, -} from '../../common/enums/messages.enum'; -import { - MatchedRequest, - PackageStatusEnum, - Prisma, - RequestStatusEnum, - TripStatusEnum, - TripTypeEnum, -} from '../../../generated/prisma'; -import { UpdateTripDto } from './dto/update-trip.dto'; -import { UpdateRequestDto } from './dto/update-request.dto'; -import { PrismaTransaction } from '../prisma/prisma.types'; -import { instanceToPlain, plainToInstance } from 'class-transformer'; -import { PriceBreakdownDto } from '../package/dto/package-response.dto'; -import { UpdateTrackingDto } from './dto/update-tracking.dto'; -import { FinancialService } from '../financial/financial.service'; -import { RateTripDto } from './dto/rate-trip.dto'; -import { isNumber } from 'class-validator'; -import { S3Service } from '../s3/s3.service'; -import { TurfService } from '../turf/turf.service'; -import { Location } from '../map/map.types'; -import { NotificationService } from '../notification/notification.service'; -import { - getNotificationMessage, - NotificationMessages, -} from '../notification/notification-messages'; - -@Injectable() -export class TripService { - constructor( - private prisma: PrismaService, - private mapService: MapService, - private financialService: FinancialService, - private s3Service: S3Service, - private turfService: TurfService, - private notificationService: NotificationService, - ) {} - - async create( - userId: string, - { - vehicleId, - waypoints, - originId, - destinationId, - ...tripDto - }: CreateTripDto, - tripType: TripTypeEnum = TripTypeEnum.intercity, - ) { - return this.prisma - .$transaction(async (tx) => { - const vehicle = await tx.vehicle.findFirst({ - where: { - id: vehicleId, - owner: { - userId, - }, - }, - }); - - if (!vehicle) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} vehicle.`, - ); - } - - const originCity = await tx.city.findUniqueOrThrow({ - where: { id: originId }, - }); - - const destinationCity = await tx.city.findUniqueOrThrow({ - where: { id: destinationId }, - }); - - const { distance, duration } = await this.mapService.calculateDistance({ - origin: { - latitude: originCity.latitude, - longitude: originCity.longitude, - }, - destination: { - latitude: destinationCity.latitude, - longitude: destinationCity.longitude, - }, - waypoints, - }); - - const tripData = { - transporterId: vehicle.ownerId, - originId, - destinationId, - vehicleId: vehicle.id, - tripType, - normalDistanceKm: distance, - normalDurationMin: duration, - ...tripDto, - } as Prisma.TripUncheckedCreateInput; - - if (waypoints) { - // TODO: Sort waypoints? - tripData.waypoints = { - createMany: { - data: waypoints, - }, - }; - } - - const trip = await tx.trip.create({ - data: tripData, - }); - - // Add create trip notification - await this.notificationService.create( - userId, - { - tripId: trip.id, - content: getNotificationMessage(NotificationMessages.TripCreated, { - tripCode: trip.code, - }), - }, - tx, - ); - - return trip; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getById(id: string) { - return this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - include: { - origin: true, - destination: true, - waypoints: { - where: { - isVisible: true, - }, - }, - vehicle: { - select: { - vehicleType: true, - model: { - include: { - brand: true, - }, - }, - manufactureYear: true, - color: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getMultipleById(ids: string[], tx: PrismaTransaction = this.prisma) { - return tx.trip - .findMany({ - where: { - id: { - in: ids, - }, - status: TripStatusEnum.scheduled, - }, - include: { - origin: true, - destination: true, - waypoints: true, - vehicle: { - include: { - model: { - include: { - brand: true, - }, - }, - }, - }, - matchedRequests: { - include: { - package: { - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAll( - userId: string, - status: TripStatusEnum[] = [ - TripStatusEnum.scheduled, - TripStatusEnum.closed, - TripStatusEnum.delayed, - TripStatusEnum.in_progress, - ], - ) { - return this.prisma.trip - .findMany({ - where: { - transporter: { - userId, - }, - status: { - in: status, - }, - deletedAt: null, - }, - include: { - origin: true, - destination: true, - waypoints: { - where: { - isVisible: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async update(id: string, { waypoints, ...tripDto }: UpdateTripDto) { - return this.prisma - .$transaction(async (tx) => { - const { status } = await tx.trip.findUniqueOrThrow({ - where: { - id, - deletedAt: null, - }, - select: { - status: true, - }, - }); - - if (status !== TripStatusEnum.scheduled) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${status}*.`, - ); - } - - const tripData = tripDto as Prisma.TripUpdateInput; - - if (waypoints) { - tripData.waypoints = { - createMany: { - data: waypoints, - }, - }; - - await tx.tripWaypoint.deleteMany({ - where: { - tripId: id, - isVisible: true, - }, - }); - } - - return tx.trip.update({ - where: { id }, - data: tripData, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async delete(id: string) { - return this.prisma - .$transaction(async (tx) => { - const { status } = await tx.trip.findUniqueOrThrow({ - where: { - id, - deletedAt: null, - }, - select: { - status: true, - }, - }); - - if (status !== TripStatusEnum.scheduled) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${status}*.`, - ); - } - - return tx.trip.update({ - where: { id }, - data: { - deletedAt: new Date(), - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllTripRequests(tripId: string) { - return this.prisma.tripRequest.findMany({ - where: { - tripId, - status: RequestStatusEnum.pending, - }, - orderBy: { - createdAt: 'asc', - }, - }); - } - - async updateRequest( - requestId: string, - { status, transporterNotes }: UpdateRequestDto, - ) { - if ( - (status as unknown as RequestStatusEnum) === RequestStatusEnum.rejected - ) { - return this.prisma - .$transaction(async (tx) => { - const { - package: packageData, - trip, - ...request - } = await this.prisma.tripRequest.update({ - where: { id: requestId }, - data: { status }, - include: { - package: { - select: { - code: true, - senderId: true, - }, - }, - trip: { - select: { - code: true, - }, - }, - }, - }); - - // Add reject request notification - await this.notificationService.create( - packageData.senderId, - { - packageId: request.packageId, - tripId: request.tripId, - content: getNotificationMessage( - NotificationMessages.TripRequestRejected, - { - packageCode: packageData.code, - tripCode: trip.code, - }, - ), - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - return this.prisma - .$transaction(async (tx) => { - const request = await tx.tripRequest.update({ - where: { id: requestId }, - data: { status }, - }); - - // Delete other sent requests for this package - await tx.tripRequest.updateMany({ - where: { - packageId: request.packageId, - NOT: { - id: request.id, - }, - }, - data: { - status: RequestStatusEnum.deleted, - }, - }); - - // Create MatchedRequest instance - const trackingCode = generateUniqueCode(); - const deliveryCode = generateCode().toString(); - await tx.matchedRequest.create({ - data: { - requestId: request.id, - packageId: request.packageId, - tripId: request.tripId, - trackingCode, - deliveryCode, - transporterNotes, - }, - }); - - // Update total deviation info in trip - const { totalDeviationDistanceKm, totalDeviationDurationMin } = - await tx.trip.findUniqueOrThrow({ - where: { id: request.tripId }, - }); - - const newTotalDeviationDistance = - (totalDeviationDistanceKm ?? 0) + request.deviationDistanceKm; - const newTotalDeviationDuration = - (totalDeviationDurationMin ?? 0) + request.deviationDurationMin; - - const { code: tripCode } = await tx.trip.update({ - where: { id: request.tripId }, - data: { - totalDeviationDistanceKm: newTotalDeviationDistance, - totalDeviationDurationMin: newTotalDeviationDuration, - }, - }); - - // Get package and Update its status and breakdown - const packageData = await tx.package.findFirstOrThrow({ - where: { id: request.packageId }, - select: { - code: true, - breakdown: true, - senderId: true, - }, - }); - - // Update breakdown - const breakdown = plainToInstance( - PriceBreakdownDto, - packageData?.breakdown, - ); - if (breakdown) { - breakdown.deviationCost = request.deviationCost ?? 0; - } - const plainBreakdown = instanceToPlain(breakdown); - - await tx.package.update({ - where: { id: request.packageId }, - data: { - status: PackageStatusEnum.matched, - finalPrice: { - increment: request.deviationCost, - }, - breakdown: plainBreakdown, - }, - }); - - // Create escrow if balance is enough - try { - await this.financialService.createEscrow({ - packageId: request.packageId, - tripId: request.tripId, - }); - } catch { - // Escrow creation failure is intentionally non-fatal here (MVP behavior); request is still accepted. - } - - // Add accept request notification - await this.notificationService.create( - packageData.senderId, - { - packageId: request.packageId, - tripId: request.tripId, - content: getNotificationMessage( - NotificationMessages.TripRequestAccepted, - { - packageCode: packageData.code, - tripCode, - }, - ), - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllMatchedRequests(tripId: string, inOrder = false) { - const matchedRequests = await this.prisma.matchedRequest - .findMany({ - where: { - tripId, - }, - select: { - package: { - select: { - id: true, - code: true, - sender: { - select: { - firstName: true, - lastName: true, - gender: true, - phoneNumber: true, - }, - }, - status: true, - items: true, - originAddress: true, - recipient: { - select: { - address: true, - }, - }, - weight: true, - dimensions: true, - packageValue: true, - isFragile: true, - isPerishable: true, - description: true, - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: true, - preferredDeliveryTime: true, - picturesKey: true, - }, - }, - request: true, - transporterNotes: true, - pickupTime: true, - deliveryTime: true, - paymentStatus: true, - }, - orderBy: { - updatedAt: 'desc', - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - let sortedMatchedRequests = matchedRequests.map((m) => ({ - ...m, - package: { - ...m.package, - items: instanceToPlain(m.package.items) as string[], - }, - })); - if (matchedRequests.length > 0 && inOrder) { - const trip = await this.prisma.trip.findFirstOrThrow({ - where: { id: tripId }, - select: { - origin: true, - destination: true, - }, - }); - sortedMatchedRequests = await this.sortMatchedPackages( - trip.origin, - trip.destination, - sortedMatchedRequests, - ); - } - - return Promise.all( - sortedMatchedRequests.map(async (m) => { - const picturesKey = instanceToPlain(m.package.picturesKey) as string[]; - const picturePromises = picturesKey?.map((k) => - this.s3Service.generateGetPresignedUrl(k), - ); - const picturesUrl = await Promise.all(picturePromises); - return { - ...m, - package: { - ...m.package, - picturesUrl, - offeredPrice: m.request.offeredPrice, - picturesKey: undefined, - }, - request: undefined, - }; - }), - ); - } - - private async sortMatchedPackages( - origin: Location, - destination: Location, - matchedRequests: any[], - ) { - const locationsMap = new Map( - matchedRequests - .map((m) => [ - m.package.id, - m.package.pickupAtOrigin && - m.package.status !== PackageStatusEnum.delivered && - m.package.status !== PackageStatusEnum.returned && - m.package.status !== PackageStatusEnum.cancelled - ? { - latitude: m.package.originAddress.latitude, - longitude: m.package.originAddress.longitude, - } - : m.package.deliveryAtDestination && - m.package.status === PackageStatusEnum.matched - ? { - latitude: m.package.recipient.address.latitude, - longitude: m.package.recipient.address.longitude, - } - : undefined, - ]) - .filter(([_, location]) => location !== undefined) as [ - string, - Location, - ][], - ); - - const sortedLocations = this.turfService.sortLocationsByRoute( - origin, - destination, - locationsMap, - ); - const sortedPackageIds = Array.from(sortedLocations.keys()); - - // Create the sorted array - const sortedRequests: any[] = []; - sortedPackageIds.forEach((packageId) => { - const request = matchedRequests.find((m) => m.package.id === packageId); - if (request) { - sortedRequests.push(request); - } - }); - - return sortedRequests; - } - - private async updateStatus( - id: string, - status: TripStatusEnum, - tx: PrismaTransaction = this.prisma, - ) { - return tx.trip.update({ - where: { id }, - data: { status }, - }); - } - - async toggleTripAccess(id: string) { - const { status: tripStatus } = await this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - select: { - status: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const isValidStatus = - tripStatus === TripStatusEnum.scheduled || - tripStatus === TripStatusEnum.closed; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, - ); - } - - const updatedStatus = - tripStatus === TripStatusEnum.scheduled - ? TripStatusEnum.closed - : TripStatusEnum.scheduled; - - return this.updateStatus(id, updatedStatus); - } - - async startTrip(id: string) { - const { status: tripStatus, origin } = await this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - select: { - status: true, - origin: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const isValidStatus = - tripStatus === TripStatusEnum.scheduled || - tripStatus === TripStatusEnum.closed || - tripStatus === TripStatusEnum.delayed; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, - ); - } - - return this.prisma - .$transaction(async (tx) => { - await this.updateTracking(id, { - city: origin.name, - description: TrackingMessages.TripStarted, - }); - - return this.updateStatus(id, TripStatusEnum.in_progress, tx); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async pickupPackage(tripId: string, packageId: string) { - const { - id: matchedRequestId, - package: packageData, - trip, - } = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - tripId, - packageId, - }, - select: { - id: true, - package: { - select: { - code: true, - senderId: true, - status: true, - originAddress: true, - }, - }, - trip: { - select: { - status: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (packageData.status !== PackageStatusEnum.matched) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, - ); - } - - if (trip.status !== TripStatusEnum.in_progress) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${packageData.status}*.`, - ); - } - - return this.prisma.$transaction(async (tx) => { - // Update package status - const { status: packageStatus } = await this.updatePackageStatus( - packageId, - PackageStatusEnum.in_transit, - ); - - // Set pickupTime - const { pickupTime } = await tx.matchedRequest.update({ - where: { - tripId, - packageId, - }, - data: { - pickupTime: new Date(), - }, - }); - - // Update tracking - await tx.trackingUpdate.create({ - data: { - matchedRequestId, - latitude: packageData.originAddress.latitude, - longitude: packageData.originAddress.longitude, - city: packageData.originAddress.city, - description: TrackingMessages.PackagePickedUp, - }, - }); - - // Add pickup package notification - await this.notificationService.create( - packageData.senderId, - { - packageId, - tripId, - content: getNotificationMessage( - NotificationMessages.PackagePickedUp, - { packageCode: packageData.code }, - ), - }, - tx, - ); - - return { - packageStatus, - pickupTime, - }; - }); - } - - async deliveryPackage(tripId: string, packageId: string, code: string) { - const { - id: matchedRequestId, - package: packageData, - trip, - deliveryCode, - } = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - tripId, - packageId, - }, - select: { - id: true, - package: { - select: { - code: true, - senderId: true, - status: true, - recipient: { - include: { - address: true, - }, - }, - }, - }, - trip: { - select: { - status: true, - }, - }, - deliveryCode: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (packageData.status !== PackageStatusEnum.in_transit) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, - ); - } - - if (trip.status !== TripStatusEnum.in_progress) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, - ); - } - - if (code !== deliveryCode) { - throw new BadRequestException(BadRequestMessages.WrongDeliveryCode); - } - - return this.prisma.$transaction(async (tx) => { - // Update package status - const { status: packageStatus } = await this.updatePackageStatus( - packageId, - PackageStatusEnum.delivered, - ); - - // Set deliveryTime - const { deliveryTime } = await tx.matchedRequest.update({ - where: { - tripId, - packageId, - }, - data: { - deliveryTime: new Date(), - }, - }); - - // Update tracking - await tx.trackingUpdate.create({ - data: { - matchedRequestId, - latitude: packageData.recipient.address.latitude, - longitude: packageData.recipient.address.longitude, - city: packageData.recipient.address.city, - description: TrackingMessages.PackageDelivered, - }, - }); - - // Release escrow - await this.financialService.releaseEscrow(packageId, tripId, tx); - - // TODO: Send SMS - - // Add delivery package notification - await this.notificationService.create( - packageData.senderId, - { - packageId, - tripId, - content: getNotificationMessage( - NotificationMessages.PackageDelivered, - { packageCode: packageData.code }, - ), - }, - tx, - ); - - return { - packageStatus, - deliveryTime, - }; - }); - } - - async finishTrip(id: string) { - const { - status: tripStatus, - matchedRequests, - transporter, - } = await this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - select: { - status: true, - matchedRequests: { - select: { - deliveryTime: true, - }, - }, - transporter: { - select: { - id: true, - firstTripDate: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (tripStatus !== TripStatusEnum.in_progress) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, - ); - } - - const allRequestsDelivered = matchedRequests.every( - (m) => m.deliveryTime !== null, - ); - if (!allRequestsDelivered) { - throw new BadRequestException(BadRequestMessages.CannotFinishTrip); - } - - // Update firstTripDate/lastTripDate - const updateTransporter: Prisma.TransporterUpdateInput = {}; - if (!transporter.firstTripDate) { - updateTransporter.firstTripDate = new Date(); - } else { - updateTransporter.lastTripDate = new Date(); - } - - return this.prisma.$transaction(async (tx) => { - // Update transporter - await tx.transporter.update({ - where: { id: transporter.id }, - data: updateTransporter, - }); - - return this.updateStatus(id, TripStatusEnum.completed, tx); - }); - } - - async addTripNote( - tripId: string, - note: string, - packageId?: string, - tx: PrismaTransaction = this.prisma, - ) { - const trip = await tx.trip - .findUniqueOrThrow({ - where: { - id: tripId, - }, - include: { - matchedRequests: { - where: { - packageId, - }, - include: { - package: { - select: { - code: true, - senderId: true, - }, - }, - }, - }, - transporter: { - select: { - user: { - select: { - firstName: true, - lastName: true, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (trip.status === TripStatusEnum.completed) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, - ); - } - - // If packageId included, the note will send for all matched requests within a trip. - const updatedMatchedRequestsPromises = trip.matchedRequests.map( - async (m) => { - // Push note - const oldNotes = - plainToInstance(Array, m.transporterNotes) ?? []; - oldNotes.push(note); - const plainNewNotes = instanceToPlain(oldNotes); - - // Add create package notification - await this.notificationService.create( - m.package.senderId, - { - packageId: m.packageId, - tripId: m.tripId, - content: getNotificationMessage( - NotificationMessages.NewTransporterNote, - { - packageCode: m.package.code, - tripCode: trip.code, - noteContent: note, - }, - ), - }, - tx, - ); - - return tx.matchedRequest.update({ - where: { - tripId, - packageId: m.packageId, - }, - data: { - transporterNotes: plainNewNotes, - }, - }); - }, - ); - - // Run queries parallel and Get 'resolved' promises's length - const results = await Promise.allSettled(updatedMatchedRequestsPromises); - const count = results.filter( - (result): result is PromiseFulfilledResult => - result.status === 'fulfilled' && result.value !== null, - ).length; - - return { - count, - }; - } - - async updateTracking( - tripId: string, - trackingDto: UpdateTrackingDto, - tx: PrismaTransaction = this.prisma, - ) { - const trip = await tx.trip - .findUniqueOrThrow({ - where: { id: tripId }, - select: { - status: true, - matchedRequests: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const trackingUpdates = trip.matchedRequests.map((m) => ({ - matchedRequestId: m.id, - ...trackingDto, - })); - return tx.trackingUpdate.createMany({ - data: trackingUpdates, - }); - } - - async getTripTracking(tripId: string, packageId: string) { - return this.prisma.trackingUpdate - .findMany({ - where: { - matchedRequest: { - tripId, - packageId, - }, - deletedAt: null, - }, - orderBy: { - createdAt: 'desc', - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async rateTrip( - userId: string, - { tripId, packageId, rate, comment }: RateTripDto, - ) { - const { - senderRating, - package: { senderId, status }, - trip: { - transporterId, - transporter: { rate: tRate, rateCount }, - }, - } = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - tripId, - packageId, - }, - select: { - senderRating: true, - package: { - select: { - senderId: true, - status: true, - }, - }, - trip: { - select: { - transporterId: true, - transporter: { - select: { - rate: true, - rateCount: true, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (senderId !== userId) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} package.`, - ); - } - - if (isNumber(senderRating)) { - throw new BadRequestException(BadRequestMessages.AlreadyRatedTrip); - } - - const isValidStatus = - status === PackageStatusEnum.delivered || - status === PackageStatusEnum.returned; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${status}*.`, - ); - } - - return this.prisma - .$transaction(async (tx) => { - // Update transporter rate - const newRateCount = rateCount + 1; - const newRate = (tRate * rateCount + rate) / newRateCount; - - await tx.transporter.update({ - where: { - id: transporterId, - }, - data: { - rate: newRate, - rateCount: newRateCount, - }, - }); - - return tx.matchedRequest.update({ - where: { - tripId, - packageId, - }, - data: { - senderRating: rate, - senderComment: comment, - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getDirections(tripId: string, origin: Location) { - const { destination } = await this.prisma.trip - .findFirstOrThrow({ - where: { id: tripId }, - select: { - destination: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - let matchedRequests = await this.prisma.matchedRequest - .findMany({ - where: { - tripId, - }, - select: { - package: { - select: { - id: true, - status: true, - originAddress: true, - recipient: { - select: { - address: true, - }, - }, - pickupAtOrigin: true, - deliveryAtDestination: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (matchedRequests.length < 1) { - return {}; - } - - matchedRequests = await this.sortMatchedPackages( - origin, - destination, - matchedRequests, - ); - - const waypoints = matchedRequests.flatMap((m) => - [ - m.package.pickupAtOrigin - ? { - latitude: m.package.originAddress.latitude, - longitude: m.package.originAddress.longitude, - } - : undefined, - m.package.deliveryAtDestination - ? { - latitude: m.package.recipient.address.latitude, - longitude: m.package.recipient.address.longitude, - } - : undefined, - ].filter((v) => v !== undefined), - ); - - const sortedWaypoints = this.turfService.sortLocationsByRoute( - origin, - destination, - waypoints, - ); - - return this.mapService.getDirections({ - origin, - destination, - waypoints: sortedWaypoints, - }); - } - - private async updatePackageStatus( - id: string, - status: PackageStatusEnum, - tx: PrismaTransaction = this.prisma, - ) { - return tx.package.update({ - where: { - id, - deletedAt: null, - }, - data: { - status, - }, - }); - } -} diff --git a/src/modules/turf/turf.service.geometry.spec.ts b/src/modules/turf/turf.service.geometry.spec.ts new file mode 100644 index 0000000..437e70a --- /dev/null +++ b/src/modules/turf/turf.service.geometry.spec.ts @@ -0,0 +1,172 @@ +import * as turf from '@turf/turf'; +import { TurfService } from './turf.service'; + +/** + * Geometry behavior with the real @turf/turf (Phase 4 Task 6). + * + * No mocks: asserts actual distances, direction checks and route sorting, + * including degenerate route inputs. + */ +describe('TurfService (real geometry)', () => { + let service: TurfService; + + beforeEach(() => { + service = new TurfService(turf); + }); + + describe('createPoint', () => { + it('should map string lat/lng to GeoJSON [lng, lat]', () => { + const point = service.createPoint({ + latitude: '35.6892', + longitude: '51.3890', + }); + + expect(point.geometry.coordinates).toEqual([51.389, 35.6892]); + }); + }); + + describe('createRoute', () => { + it('should build a 2-point line without waypoints', () => { + const route = service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.01' }, + ); + + expect(route.geometry.coordinates).toEqual([ + [0, 0], + [0.01, 0], + ]); + }); + + it('should splice waypoints between origin and destination', () => { + const route = service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.02' }, + [{ latitude: '0', longitude: '0.01' }], + ); + + expect(route.geometry.coordinates).toEqual([ + [0, 0], + [0.01, 0], + [0.02, 0], + ]); + }); + }); + + describe('getDistanceToRoute', () => { + const route = () => + service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.01' }, + ); + + it('should return near-zero for a point on the route', () => { + const distance = service.getDistanceToRoute( + service.createPoint({ latitude: '0', longitude: '0.005' }), + route(), + ); + + expect(distance).toBeLessThan(5); + }); + + it('should return a large distance for a far point', () => { + const distance = service.getDistanceToRoute( + service.createPoint({ latitude: '1', longitude: '1' }), + route(), + ); + + expect(distance).toBeGreaterThan(100_000); + }); + + it('should return a finite distance for a degenerate single-point route', () => { + const single = { latitude: '0', longitude: '0' }; + const distance = service.getDistanceToRoute( + service.createPoint({ latitude: '0', longitude: '0.001' }), + service.createRoute(single, single), + ); + + expect(Number.isFinite(distance)).toBe(true); + expect(distance).toBeGreaterThanOrEqual(0); + }); + }); + + describe('checkDirectionCompatibility', () => { + // NOTE: the implementation compares turf segment indices, so routes + // need at least 3 vertices for origin/destination to land on different + // segments (a 2-vertex route always compares 0 > 0). + const route = () => + service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.02' }, + [{ latitude: '0', longitude: '0.01' }], + ); + + it('should accept destination after origin along the route', () => { + expect( + service.checkDirectionCompatibility( + route(), + service.createPoint({ latitude: '0', longitude: '0.002' }), + service.createPoint({ latitude: '0', longitude: '0.015' }), + ), + ).toBe(true); + }); + + it('should reject destination before origin along the route', () => { + expect( + service.checkDirectionCompatibility( + route(), + service.createPoint({ latitude: '0', longitude: '0.015' }), + service.createPoint({ latitude: '0', longitude: '0.002' }), + ), + ).toBe(false); + }); + }); + + describe('sortLocationsByRoute', () => { + const origin = { latitude: '0', longitude: '0' }; + const destination = { latitude: '0', longitude: '0.02' }; + + it('should sort an array by distance from origin', () => { + const far = { latitude: '0', longitude: '0.015' }; + const near = { latitude: '0', longitude: '0.005' }; + + expect( + service.sortLocationsByRoute(origin, destination, [far, near]), + ).toEqual([near, far]); + }); + + it('should return a copy for zero or one locations', () => { + const single = [{ latitude: '0', longitude: '0.005' }]; + + expect(service.sortLocationsByRoute(origin, destination, [])).toEqual([]); + const sorted = service.sortLocationsByRoute(origin, destination, single); + expect(sorted).toEqual(single); + expect(sorted).not.toBe(single); + }); + + it('should sort a Map by distance from origin', () => { + const locations = new Map([ + ['far', { latitude: '0', longitude: '0.015' }], + ['near', { latitude: '0', longitude: '0.005' }], + ]); + + const sorted = service.sortLocationsByRoute( + origin, + destination, + locations, + ); + + expect(Array.from(sorted.keys())).toEqual(['near', 'far']); + }); + + it('should copy a Map with one or zero entries', () => { + const single = new Map([['only', { latitude: '0', longitude: '0.005' }]]); + + const sorted = service.sortLocationsByRoute(origin, destination, single); + expect(Array.from(sorted.entries())).toEqual( + Array.from(single.entries()), + ); + expect(sorted).not.toBe(single); + }); + }); +});