From eccc3c07db26beb29e89c2ca15f912f685ec9b14 Mon Sep 17 00:00:00 2001 From: johnkoye19 <93558021+johnkoye19@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:35:59 +0100 Subject: [PATCH] refactor(backend): consolidate delivery controllers, remove unsafe casts, unify query contract & extract provider interfaces (#219, #220, #221, #222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #219 — collapse the triple delivery controllers into one canonical src/controllers/delivery.controller.ts (plus a re-export shim and DI cleanup). #220 — remove `as unknown as` / `as any` casts across auth, delivery, notification, repository and user layers by introducing typed projections (UserDTO), zod parsing and proper AppError typing. #221 — roll the shared query middleware out to every list endpoint (deliveries, users/deleted, disputes, fleets, notifications, webhooks, event log) with a uniform buildPaginationMeta contract, documented in docs/query-contract.md, and add tests/integration/queryContract.test.ts covering all seven endpoints end to end. #222 — extract external SDK clients behind interfaces (IRoutingProvider, ISorobanRpcClient, IImagesStorage) with swappable adapters, injected through awilix DI, so services no longer import Google Maps or the Stellar SDK directly. Also fixes two pre-existing crashes that blocked app-boot test suites (required for #221's integration tests): rateLimiter TEST_UNLIMITED_MAX undefined and delivery.routes validateRequest not imported. Verification: tsc --noEmit errors 79 -> 76 (no new errors; 3 removed by the bug fixes), eslint 0 errors in touched files, jest 16 failing suites vs 30 at baseline (14 suites fixed, 0 new failures; remaining failures are pre-existing upstream drift), tsc build unchanged. Closes #219 Closes #220 Closes #221 Closes #222 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- docs/architecture.md | 10 +- docs/query-contract.md | 58 ++++ src/blockchain/soroban.service.ts | 8 +- src/controllers/delivery.controller.ts | 76 ++-- src/controllers/deliveryController.ts | 37 +- src/controllers/deliveryCrudController.ts | 167 --------- src/controllers/disputeController.ts | 39 +-- src/controllers/eventLogController.ts | 14 +- src/controllers/fleetController.ts | 30 +- src/controllers/notificationController.ts | 24 +- src/controllers/poolingController.ts | 2 +- src/controllers/userController.ts | 31 +- src/controllers/webhookController.ts | 16 +- src/di/container.ts | 9 +- src/di/tokens.ts | 6 +- src/indexer/escrowHandlers.ts | 16 +- src/middlewares/queryMiddleware.ts | 22 ++ src/middlewares/rateLimiter.ts | 3 + src/repositories/BaseRepository.ts | 9 +- src/repositories/DeliveryRepository.ts | 4 +- src/repositories/NotificationRepository.ts | 8 +- src/repositories/types.ts | 13 +- src/routes/delivery.routes.ts | 23 +- src/routes/deliveryRoutes.ts | 2 +- src/routes/disputeRoutes.ts | 19 +- src/routes/eventLogRoutes.ts | 9 +- src/routes/fleetRoutes.ts | 14 +- src/routes/notificationRoutes.ts | 16 +- src/routes/poolingRoutes.ts | 2 +- src/routes/userRoutes.ts | 11 + src/routes/webhookRoutes.ts | 14 +- src/services/authService.ts | 86 ++++- src/services/delivery.service.ts | 44 +-- src/services/deliveryService.ts | 22 +- src/services/disputeService.ts | 46 +-- src/services/eventLogService.ts | 33 +- src/services/indexerService.ts | 11 +- src/services/notificationService.ts | 29 +- src/services/poolingService.ts | 38 +- src/services/providers/adapters.ts | 212 ++++++++++++ src/services/providers/imagesStorage.ts | 20 ++ src/services/providers/routingProvider.ts | 50 +++ src/services/providers/sorobanRpcClient.ts | 51 +++ src/services/routingService.ts | 184 ++-------- src/services/stellarService.ts | 68 ++-- src/services/transactionService.ts | 9 +- src/services/userService.ts | 43 +-- src/services/webhookService.ts | 41 ++- tests/delivery.test.ts | 2 +- tests/di.container.test.ts | 3 + tests/dispute.test.ts | 6 +- tests/disputeRoutes.test.ts | 5 +- tests/integration/queryContract.test.ts | 381 +++++++++++++++++++++ tests/notificationService.test.ts | 4 +- tests/providers.test.ts | 240 +++++++++++++ 55 files changed, 1642 insertions(+), 698 deletions(-) create mode 100644 docs/query-contract.md delete mode 100644 src/controllers/deliveryCrudController.ts create mode 100644 src/services/providers/adapters.ts create mode 100644 src/services/providers/imagesStorage.ts create mode 100644 src/services/providers/routingProvider.ts create mode 100644 src/services/providers/sorobanRpcClient.ts create mode 100644 tests/integration/queryContract.test.ts create mode 100644 tests/providers.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 88e13bc..2b33699 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,9 +189,8 @@ SwiftChain_Backend/ │ │ └── fcmProvider.ts # Firebase Cloud Messaging implementation │ │ │ ├── controllers/ # HTTP request handlers -│ │ ├── delivery.controller.ts # Full delivery CRUD (uses delivery.service.ts) -│ │ ├── deliveryController.ts # ⚠ DEPRECATED — ETA endpoint only -│ │ ├── deliveryCrudController.ts # ⚠ DEPRECATED — basic CRUD exports +│ │ ├── delivery.controller.ts # Canonical delivery controller (CRUD + ETA, uses delivery.service.ts) +│ │ ├── deliveryController.ts # ⚠ DEPRECATED — compatibility shim re-exporting delivery.controller.ts │ │ ├── deliveryStatusController.ts # Status transitions │ │ ├── escrow.controller.ts # Escrow by delivery/contract + fund trigger │ │ ├── escrowController.ts # ⚠ DEPRECATED — admin flagged escrows @@ -865,9 +864,8 @@ New routes should use the middleware in `src/middlewares/`. | Status | File | Usage | |---|---|---| -| Active | `src/controllers/delivery.controller.ts` | Full delivery CRUD via `delivery.service.ts` | -| **DEPRECATED** | `src/controllers/deliveryController.ts` | ETA endpoint only (`GET /:id/eta`), wires to legacy `deliveryService` | -| **DEPRECATED** | `src/controllers/deliveryCrudController.ts` | Exports `createDelivery`, `getDeliveries`, `getDeliveryById`, `assignDriver` as standalone functions | +| Active | `src/controllers/delivery.controller.ts` | Full delivery CRUD (incl. `GET /:id/eta`) via `delivery.service.ts` | +| **DEPRECATED** | `src/controllers/deliveryController.ts` | Compatibility shim — re-exports the canonical controller from `delivery.controller.ts` | #### Routes diff --git a/docs/query-contract.md b/docs/query-contract.md new file mode 100644 index 0000000..eb1fc50 --- /dev/null +++ b/docs/query-contract.md @@ -0,0 +1,58 @@ +# Shared Query Contract + +All list endpoints expose a single, consistent query interface backed by +`buildQueryOptions` (`src/middlewares/queryMiddleware.ts`). This document is +the contract clients can rely on. + +## Parameters + +| Parameter | Type | Default | Description | +| --------- | ---- | ------- | ----------- | +| `page` | positive integer | `1` | 1-based page number. | +| `limit` | positive integer | route default (10/20) | Page size, clamped to the route's `maxLimit` (100 by default). | +| `sort` | comma-separated string | route default (`-createdAt`) | e.g. `sort=-createdAt,name`. Leading `-` = descending. Fields outside the route's whitelist are rejected with 400. | +| `search` | string | — | Free-text match against the route's `searchableFields` (case-insensitive, literal substring). | +| `` | route-declared type | — | Direct equality filter, e.g. `?status=pending`. Only whitelisted fields are accepted. | +| `[op]` | — | — | Comparison filter where `op` ∈ `eq, ne, gt, gte, lt, lte, in, nin`, e.g. `?createdAt[gte]=2026-01-01` or `?status[in]=pending,assigned`. Non-whitelisted operators/fields are rejected with 400. | + +## Pagination metadata + +Every paginated list response returns the same meta shape (inside the +response's `data`, alongside the collection): + +```json +{ + "totalItems": 42, + "totalPages": 5, + "currentPage": 1, + "limit": 10, + "hasNextPage": true, + "hasPreviousPage": false, + "nextPage": 2, + "previousPage": null +} +``` + +Built by `buildPaginationMeta(totalItems, page, limit)`. + +## Notes + +- Invalid `page`/`limit`/`sort`/filter values produce a 400 with a descriptive + message — they are never silently coerced or ignored. +- Unknown query-string fields are ignored, so adding new whitelisted fields + later is backward compatible. +- Errors surface through the global error handler in the standard envelope + (`success: false`, `error` populated). + +## Endpoint configuration + +| Endpoint | Filterable fields | Searchable | Sortable | +| -------- | ----------------- | ---------- | -------- | +| `GET /v1/deliveries` | `status`, `driver` | `trackingNumber`, `customer.name`, `customer.phone` | `createdAt` | +| `GET /v1/deliveries/archived` | — | — | `createdAt` | +| `GET /v1/users/deleted` | `role`, `status` | `email`, `firstName`, `lastName` | `deletedAt` | +| `GET /v1/disputes` | `status`, `reason`, `raisedBy`, `deliveryId` | — | `createdAt` | +| `GET /v1/fleets` | `isActive` | `name` | `createdAt` | +| `GET /v1/notifications` | `status`, `event` | — | `createdAt` | +| `GET /v1/webhooks` | `isActive` | — | `createdAt` | +| `GET /v1/eventlog/unprocessed` | — | — | `createdAt` | diff --git a/src/blockchain/soroban.service.ts b/src/blockchain/soroban.service.ts index 69a9e6e..cad0205 100644 --- a/src/blockchain/soroban.service.ts +++ b/src/blockchain/soroban.service.ts @@ -1,10 +1,12 @@ import { rpc as StellarRpc } from '@stellar/stellar-sdk'; import CircuitBreaker from 'opossum'; import logger from '../config/logger'; -import { sorobanRpcClient, stellarConfig } from '../config/stellar'; +import { stellarConfig } from '../config/stellar'; import env from '../config/env'; import { withRetry, RetryOptions } from '../utils/rpcRetry'; import { createCircuitBreaker, fireWithBreaker } from '../utils/circuitBreaker'; +import type { ISorobanRpcClient } from '../services/providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from '../services/providers/adapters'; /** * Result returned by a successful connectivity check. @@ -55,7 +57,7 @@ export interface DegradedLedgerResult { * its own errors — is not double-wrapped. */ export class SorobanService { - private readonly client: StellarRpc.Server; + private readonly client: ISorobanRpcClient; /** * Circuit breaker for this instance's Soroban RPC operations. @@ -67,7 +69,7 @@ export class SorobanService { /** Counter so each service instance owns an isolated breaker. */ private static instanceCount = 0; - constructor(client: StellarRpc.Server = sorobanRpcClient) { + constructor(client: ISorobanRpcClient = defaultSorobanRpcClient) { this.client = client; this.breaker = createCircuitBreaker<[() => Promise], unknown>( diff --git a/src/controllers/delivery.controller.ts b/src/controllers/delivery.controller.ts index ae9e994..804af93 100644 --- a/src/controllers/delivery.controller.ts +++ b/src/controllers/delivery.controller.ts @@ -1,14 +1,16 @@ import { Request, Response, NextFunction } from 'express'; import httpStatus from 'http-status-codes'; -import { DeliveryStatus } from '../models/Delivery'; import { deliveryService, CreateDeliveryInput, UpdateDeliveryInput, - DeliveryFilter, AssignDriverInput, } from '../services/delivery.service'; -import { sendSuccess } from '../utils/responseWrapper'; +// ETA lookups live in the dedicated delivery service; aliased so the two +// service modules can be imported side by side. +import { deliveryService as deliveryEtaService } from '../services/deliveryService'; +import { sendSuccess, sendError } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; interface AuthenticatedRequest extends Request { user?: { id: string }; @@ -46,35 +48,47 @@ export class DeliveryController { } } + /** + * GET /api/v1/deliveries/:id/eta + * + * Calculates the delivery ETA from the stored pickup/dropoff coordinates. + * + * Responses: + * 200 — ETA calculated successfully. + * 400 — delivery ID missing. + * 404 — delivery not found. + * 500 — routing failure or delivery without complete coordinates. + */ + async getDeliveryETA(req: Request, res: Response, _next: NextFunction): Promise { + const { id } = req.params; + + if (!id) { + sendError(res, 'Delivery ID is required', httpStatus.BAD_REQUEST); + return; + } + + try { + const result = await deliveryEtaService.calculateDeliveryETA({ deliveryId: id }); + sendSuccess(res, result, 'ETA calculated successfully', httpStatus.OK); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + const statusCode = errorMessage.includes('not found') + ? httpStatus.NOT_FOUND + : httpStatus.INTERNAL_SERVER_ERROR; + sendError(res, errorMessage || 'Failed to calculate ETA', statusCode); + } + } + async list(req: Request, res: Response, next: NextFunction): Promise { try { - const statusParam = req.query.status as string | undefined; - const statusNormalized = statusParam ? statusParam.toLowerCase() : undefined; - const validatedStatus = Object.values(DeliveryStatus).includes( - statusNormalized as DeliveryStatus, - ) - ? (statusNormalized as DeliveryStatus) - : undefined; - - const filters: DeliveryFilter = { - status: validatedStatus, - driver: req.query.driver as string | undefined, - search: req.query.search as string | undefined, - page: req.query.page ? parseInt(req.query.page as string, 10) : 1, - limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 10, - }; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const result = await deliveryService.list(filters); + const result = await deliveryService.list({ filter, page, limit, sort }); sendSuccess( res, { deliveries: result.data, - meta: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }, 'Deliveries retrieved successfully', httpStatus.OK, @@ -123,20 +137,14 @@ export class DeliveryController { async listArchived(req: Request, res: Response, next: NextFunction): Promise { try { - const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; - const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; + const { page, limit, sort } = resolveQueryOptions(req); - const result = await deliveryService.listArchived(page, limit); + const result = await deliveryService.listArchived(page, limit, sort); sendSuccess( res, { deliveries: result.data, - meta: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }, 'Archived deliveries retrieved successfully', httpStatus.OK, diff --git a/src/controllers/deliveryController.ts b/src/controllers/deliveryController.ts index d2f3b75..c219290 100644 --- a/src/controllers/deliveryController.ts +++ b/src/controllers/deliveryController.ts @@ -1,28 +1,9 @@ -import { Request, Response } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import { deliveryService } from '../services/deliveryService'; -import { sendSuccess, sendError } from '../utils/responseWrapper'; - -class DeliveryController { - async getDeliveryETA(req: Request, res: Response): Promise { - const { id } = req.params; - - if (!id) { - sendError(res, 'Delivery ID is required', StatusCodes.BAD_REQUEST); - return; - } - - try { - const result = await deliveryService.calculateDeliveryETA({ deliveryId: id }); - sendSuccess(res, result, 'ETA calculated successfully', StatusCodes.OK); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - const statusCode = errorMessage.includes('not found') - ? StatusCodes.NOT_FOUND - : StatusCodes.INTERNAL_SERVER_ERROR; - sendError(res, errorMessage || 'Failed to calculate ETA', statusCode); - } - } -} - -export const deliveryController = new DeliveryController(); +/** + * @deprecated Deprecated compatibility shim — canonical implementation lives in + * `delivery.controller.ts` (single canonical delivery controller). + * + * Re-exports the canonical `DeliveryController` singleton so existing imports + * of `./deliveryController` keep working during the migration. New code must + * import from `./delivery.controller` instead. + */ +export { DeliveryController, deliveryController } from './delivery.controller'; diff --git a/src/controllers/deliveryCrudController.ts b/src/controllers/deliveryCrudController.ts deleted file mode 100644 index 99302c5..0000000 --- a/src/controllers/deliveryCrudController.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import crypto from 'crypto'; -import mongoose from 'mongoose'; -import Delivery, { DeliveryStatus } from '../models/Delivery'; -import { sendSuccess, sendError } from '../utils/responseWrapper'; - -// POST /api/v1/deliveries -export const createDelivery = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { sender, recipient, packageDescription, weight, estimatedValue, notes } = req.body; - - if (!sender?.name || !sender?.contact || !sender?.address) { - sendError( - res, - 'sender.name, sender.contact, and sender.address are required', - StatusCodes.BAD_REQUEST, - ); - return; - } - if (!recipient?.name || !recipient?.contact || !recipient?.address) { - sendError( - res, - 'recipient.name, recipient.contact, and recipient.address are required', - StatusCodes.BAD_REQUEST, - ); - return; - } - if (!packageDescription) { - sendError(res, 'packageDescription is required', StatusCodes.BAD_REQUEST); - return; - } - - const trackingId = `SWIFT-${crypto.randomUUID().replace(/-/g, '').slice(0, 10).toUpperCase()}`; - - const delivery = await Delivery.create({ - trackingId, - sender, - recipient, - packageDescription, - weight, - estimatedValue, - notes, - }); - - sendSuccess(res, delivery, 'Delivery created successfully', StatusCodes.CREATED); - } catch (err) { - next(err); - } -}; - -// GET /api/v1/deliveries -export const getDeliveries = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const page = Math.max(1, parseInt(req.query.page as string) || 1); - const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 10)); - const skip = (page - 1) * limit; - - const filter: Record = {}; - if (req.query.status) filter.status = req.query.status; - - const sortOrder = req.query.order === 'asc' ? 1 : -1; - - const [deliveries, total] = await Promise.all([ - Delivery.find(filter).sort({ createdAt: sortOrder }).skip(skip).limit(limit).lean(), - Delivery.countDocuments(filter), - ]); - - sendSuccess( - res, - { - deliveries, - pagination: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }, - 'Deliveries retrieved successfully', - StatusCodes.OK, - ); - } catch (err) { - next(err); - } -}; - -// GET /api/v1/deliveries/:id -export const getDeliveryById = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - sendError(res, 'Invalid delivery ID', StatusCodes.BAD_REQUEST); - return; - } - - const delivery = await Delivery.findById(id).lean(); - - if (!delivery) { - sendError(res, 'Delivery not found', StatusCodes.NOT_FOUND); - return; - } - - sendSuccess(res, delivery, 'Delivery retrieved successfully', StatusCodes.OK); - } catch (err) { - next(err); - } -}; - -// PUT /api/v1/deliveries/:id/assign -export const assignDriver = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { id } = req.params; - const { driverId } = req.body; - - if (!mongoose.Types.ObjectId.isValid(id)) { - sendError(res, 'Invalid delivery ID', StatusCodes.BAD_REQUEST); - return; - } - - if (!driverId || typeof driverId !== 'string' || !driverId.trim()) { - sendError(res, 'driverId is required', StatusCodes.BAD_REQUEST); - return; - } - - const delivery = await Delivery.findById(id); - - if (!delivery) { - sendError(res, 'Delivery not found', StatusCodes.NOT_FOUND); - return; - } - - if (delivery.status !== DeliveryStatus.PENDING) { - sendError( - res, - `Cannot assign driver to a delivery with status '${delivery.status}'`, - StatusCodes.CONFLICT, - ); - return; - } - - delivery.driverId = driverId.trim(); - delivery.status = DeliveryStatus.ASSIGNED; - await delivery.save(); - - sendSuccess(res, delivery, 'Driver assigned successfully', StatusCodes.OK); - } catch (err) { - next(err); - } -}; diff --git a/src/controllers/disputeController.ts b/src/controllers/disputeController.ts index f683159..ca110ab 100644 --- a/src/controllers/disputeController.ts +++ b/src/controllers/disputeController.ts @@ -13,12 +13,11 @@ import type { ResolveDisputeInput, AddEvidenceInput, UpdateDisputeInput, - DisputeFilter, } from '../validators/disputeValidator'; import type { IUser } from '../interfaces/IUser'; import AppError from '../utils/AppError'; -import { DisputeReason, DisputeStatus } from '../models/Dispute'; import { sendSuccess } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; // ─── POST /api/v1/disputes ────────────────────────────────────── @@ -73,45 +72,15 @@ export const listDisputes = async ( next: NextFunction, ): Promise => { try { - const statusParam = req.query.status as string | undefined; - const status = - statusParam && Object.values(DisputeStatus).includes(statusParam as DisputeStatus) - ? (statusParam as DisputeStatus) - : undefined; - - const raisedBy = req.query.raisedBy as string | undefined; - const deliveryIdParam = req.query.deliveryId as string | undefined; - - const reasonParam = req.query.reason as string | undefined; - const reason = - reasonParam && Object.values(DisputeReason).includes(reasonParam as DisputeReason) - ? (reasonParam as DisputeReason) - : undefined; - - const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; - const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; - - const filters: DisputeFilter = { - status, - raisedBy, - deliveryId: deliveryIdParam, - reason, - page, - limit, - }; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const result = await getDisputes(filters); + const result = await getDisputes({ filter, page, limit, sort }); sendSuccess( res, { disputes: result.data, - meta: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }, 'Disputes retrieved successfully', StatusCodes.OK, diff --git a/src/controllers/eventLogController.ts b/src/controllers/eventLogController.ts index 4e50683..23bca53 100644 --- a/src/controllers/eventLogController.ts +++ b/src/controllers/eventLogController.ts @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes'; import eventLogService from '../services/eventLogService'; import { sendSuccess, sendError } from '../utils/responseWrapper'; import logger from '../config/logger'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; export class EventLogController { /** @@ -33,8 +34,17 @@ export class EventLogController { */ async getUnprocessedEvents(req: Request, res: Response, next: NextFunction): Promise { try { - const events = await eventLogService.getUnprocessedEvents(); - sendSuccess(res, events, 'Unprocessed events retrieved successfully', StatusCodes.OK); + const { page, limit, sort } = resolveQueryOptions(req); + const result = await eventLogService.getUnprocessedEvents(page, limit, sort); + sendSuccess( + res, + { + events: result.data, + meta: buildPaginationMeta(result.total, result.page, result.limit), + }, + 'Unprocessed events retrieved successfully', + StatusCodes.OK, + ); } catch (error) { logger.error('Error in getUnprocessedEvents:', error); next(error); diff --git a/src/controllers/fleetController.ts b/src/controllers/fleetController.ts index c4dc86b..35f0716 100644 --- a/src/controllers/fleetController.ts +++ b/src/controllers/fleetController.ts @@ -11,6 +11,7 @@ import mongoose from 'mongoose'; import Fleet from '../models/Fleet'; import User from '../models/User'; import { sendSuccess } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; // ─── Request body types ──────────────────────────────────────────────────────── @@ -207,29 +208,24 @@ export const getAllFleets = async ( throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); } - const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 10; - const skip = (page - 1) * limit; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const fleets = await Fleet.find({ isActive: true }) - .skip(skip) - .limit(limit) - .sort({ createdAt: -1 }) - .populate('ownerId', 'name email') - .populate('members.userId', 'name email role'); - - const total = await Fleet.countDocuments({ isActive: true }); + const query = { isActive: true, ...filter }; + const [fleets, total] = await Promise.all([ + Fleet.find(query) + .sort(sort) + .skip((page - 1) * limit) + .limit(limit) + .populate('ownerId', 'name email') + .populate('members.userId', 'name email role'), + Fleet.countDocuments(query), + ]); sendSuccess( res, { fleets, - pagination: { - page, - limit, - total, - pages: Math.ceil(total / limit), - }, + meta: buildPaginationMeta(total, page, limit), }, 'Fleets retrieved successfully', StatusCodes.OK, diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts index d0d956b..07b01e3 100644 --- a/src/controllers/notificationController.ts +++ b/src/controllers/notificationController.ts @@ -5,6 +5,7 @@ import type { IUser } from '../interfaces/IUser'; import { NotificationEvent } from '../models/NotificationPreference'; import { notificationService } from '../services/notificationService'; import AppError from '../utils/AppError'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; /** * NotificationController — HTTP surface for push notification preferences, @@ -38,10 +39,8 @@ export const unregisterDeviceSchema = z.object({ }); /** Query accepted by `GET /api/v1/notifications`. */ -export const listNotificationsSchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(20), -}); +// Pagination/validation for GET /notifications is handled by the shared +// `buildQueryOptions` middleware; the controller reads `req.queryOptions`. // ─── Helpers ─────────────────────────────────────────────────────────────────── @@ -181,18 +180,19 @@ export const listNotifications = async ( next: NextFunction, ): Promise => { try { - const { page, limit } = req.query as unknown as z.infer; - const result = await notificationService.listForUser(requireUserId(req), page, limit); + const { filter, page, limit, sort } = resolveQueryOptions(req); + + const result = await notificationService.listForUser(requireUserId(req), { + filter, + page, + limit, + sort, + }); res.status(StatusCodes.OK).json({ status: 'success', data: result.data, - pagination: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }); } catch (error) { next(error); diff --git a/src/controllers/poolingController.ts b/src/controllers/poolingController.ts index 21f64bb..869374d 100644 --- a/src/controllers/poolingController.ts +++ b/src/controllers/poolingController.ts @@ -40,4 +40,4 @@ export const poolAndAssign = async ( } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index e906759..a12421c 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -6,7 +6,7 @@ import AppError from '../utils/AppError'; import asyncHandler from '../utils/asyncHandler'; import { sendSuccess } from '../utils/responseWrapper'; import type { AuthenticatedRequest } from '../middlewares/authMiddleware'; -import { UserRole, UserStatus } from '../interfaces/IUser'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; class UserController { /** @@ -204,37 +204,14 @@ class UserController { */ public listDeletedUsers = asyncHandler( async (req: Request, res: Response, _next: NextFunction): Promise => { - const { - role, - status, - search, - page = '1', - limit = '10', - } = req.query as Record; - - const parsedPage = Math.max(1, parseInt(page as string, 10) || 1); - const parsedLimit = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 10)); - - const filters: Parameters[0] = { - page: parsedPage, - limit: parsedLimit, - }; - - if (role) filters.role = role as UserRole; - if (status) filters.status = status as UserStatus; - if (search) filters.search = search as string; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const result = await userService.getDeletedUsers(filters); + const result = await userService.getDeletedUsers({ filter, page, limit, sort }); res.status(StatusCodes.OK).json({ status: 'success', data: result.data, - pagination: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + pagination: buildPaginationMeta(result.total, result.page, result.limit), }); }, ); diff --git a/src/controllers/webhookController.ts b/src/controllers/webhookController.ts index 7775aa8..f88f6f9 100644 --- a/src/controllers/webhookController.ts +++ b/src/controllers/webhookController.ts @@ -5,6 +5,7 @@ import type { RegisterWebhookInput, UpdateWebhookInput } from '../validators/web import type { IUser } from '../interfaces/IUser'; import AppError from '../utils/AppError'; import { sendSuccess } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; function requireUser(req: Request): IUser { const user = (req as Request & { user?: IUser }).user; @@ -51,8 +52,19 @@ export const listWebhooks = async ( ): Promise => { try { const user = requireUser(req); - const webhooks = await webhookService.listForMerchant(user._id.toString()); - sendSuccess(res, { webhooks }, 'Webhooks retrieved successfully', StatusCodes.OK); + const { filter, page, limit, sort } = resolveQueryOptions(req); + const result = await webhookService.listForMerchant(user._id.toString(), { + filter, + page, + limit, + sort, + }); + sendSuccess( + res, + { webhooks: result.data, meta: buildPaginationMeta(result.total, result.page, result.limit) }, + 'Webhooks retrieved successfully', + StatusCodes.OK, + ); } catch (error) { next(error); } diff --git a/src/di/container.ts b/src/di/container.ts index 616c29d..db8cc24 100644 --- a/src/di/container.ts +++ b/src/di/container.ts @@ -64,12 +64,13 @@ import * as evidenceService from '../services/evidenceService'; import { indexerService } from '../services/indexerService'; import * as monitorService from '../services/monitorService'; import { idempotencyService } from '../services/idempotency.service'; +import { SdkSorobanRpcClient } from '../services/providers/adapters'; +import { getStorageDriver } from '../services/storage.service'; // ─── Controllers ─────────────────────────────────────────────────────────── // Import controller singleton instances (already instantiated in their modules) import authController from '../controllers/authController'; import { deliveryController } from '../controllers/delivery.controller'; -import * as deliveryCrudController from '../controllers/deliveryCrudController'; import * as deliveryStatusController from '../controllers/deliveryStatusController'; import { driverController } from '../controllers/driverController'; import { driverRatingController } from '../controllers/driverRatingController'; @@ -150,6 +151,11 @@ export function createDIContainer(): AwilixContainer { [TOKENS.indexerService]: asValue(indexerService), [TOKENS.monitorService]: asValue(monitorService), [TOKENS.idempotencyService]: asValue(idempotencyService), + + // ─── External SDK clients behind provider interfaces (issue #222) ───── + [TOKENS.routingProvider]: asValue(routingService), + [TOKENS.sorobanRpcClient]: asValue(new SdkSorobanRpcClient()), + [TOKENS.imagesStorage]: asValue(getStorageDriver()), }); // ─── Register Controllers (Singleton) ────────────────────────────────── @@ -161,7 +167,6 @@ export function createDIContainer(): AwilixContainer { container.register({ [TOKENS.authController]: asValue(authController), [TOKENS.deliveryController]: asValue(deliveryController), - [TOKENS.deliveryCrudController]: asValue(deliveryCrudController), [TOKENS.deliveryStatusController]: asValue(deliveryStatusController), [TOKENS.driverController]: asValue(driverController), [TOKENS.driverRatingController]: asValue(driverRatingController), diff --git a/src/di/tokens.ts b/src/di/tokens.ts index a00e1d6..4f0216d 100644 --- a/src/di/tokens.ts +++ b/src/di/tokens.ts @@ -34,6 +34,11 @@ export const TOKENS = { monitorService: 'monitorService', idempotencyService: 'idempotencyService', + // External SDK clients behind provider interfaces (issue #222) + routingProvider: 'routingProvider', + sorobanRpcClient: 'sorobanRpcClient', + imagesStorage: 'imagesStorage', + // Models (Mongoose schemas) userModel: 'userModel', deliveryModel: 'deliveryModel', @@ -58,7 +63,6 @@ export const TOKENS = { // Controllers authController: 'authController', deliveryController: 'deliveryController', - deliveryCrudController: 'deliveryCrudController', deliveryStatusController: 'deliveryStatusController', driverController: 'driverController', driverRatingController: 'driverRatingController', diff --git a/src/indexer/escrowHandlers.ts b/src/indexer/escrowHandlers.ts index 7fea171..7936d7d 100644 --- a/src/indexer/escrowHandlers.ts +++ b/src/indexer/escrowHandlers.ts @@ -1,10 +1,24 @@ import { rpc as StellarRpc, scValToNative, xdr } from '@stellar/stellar-sdk'; -import { sorobanRpcClient } from '../config/stellar'; import { escrowIndexerConfig } from '../config/escrow'; import { escrowService, EscrowFundedInput } from '../services/escrow.service'; import { escrowIndexerService } from '../services/escrowIndexerService'; import { EscrowResolvedEvent } from './types/escrowEvents'; import logger from '../config/logger'; +import type { ISorobanRpcClient } from '../services/providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from '../services/providers/adapters'; + +/** + * Soroban RPC client used by the escrow event sync functions. + * + * Defaults to the shared SDK adapter; tests can swap in a fake via + * {@link setEscrowRpcClient} instead of reaching for module mocking. + */ +let sorobanRpcClient: ISorobanRpcClient = defaultSorobanRpcClient; + +/** Replace the RPC client used by the escrow handlers (test seam). */ +export function setEscrowRpcClient(client: ISorobanRpcClient): void { + sorobanRpcClient = client; +} /** * Native representation of the `escrow_funded` event emitted by the escrow diff --git a/src/middlewares/queryMiddleware.ts b/src/middlewares/queryMiddleware.ts index e040976..ad2fc19 100644 --- a/src/middlewares/queryMiddleware.ts +++ b/src/middlewares/queryMiddleware.ts @@ -268,6 +268,28 @@ export const resolveQueryOptions = (req: Request): QueryOptions => filter: {}, }; +/** + * Merges the middleware's `search` `$and` wrapper into a flat filter object + * so services can destructure their whitelisted keys directly. + * + * The combined filter is already a valid Mongoose query; this helper is only + * for services that need to inspect or normalise individual fields (e.g. to + * validate an enum value) before executing the query. + */ +export const flattenQueryFilter = (filter: FilterQuery): Record => { + const { $and, ...rest } = filter as Record; + + if (!Array.isArray($and)) { + return { ...rest }; + } + + const merged = $and.map((part) => + typeof part === 'object' && part !== null ? (part as Record) : {}, + ); + + return { ...rest, ...Object.assign({}, ...merged) }; +}; + /** Derives the pagination metadata returned with every paginated response. */ export const buildPaginationMeta = ( totalItems: number, diff --git a/src/middlewares/rateLimiter.ts b/src/middlewares/rateLimiter.ts index 97b28bd..b4e9fb6 100644 --- a/src/middlewares/rateLimiter.ts +++ b/src/middlewares/rateLimiter.ts @@ -3,6 +3,9 @@ import env from '../config/env'; const isTest = env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; +/** Effectively-unbounded request cap used to disable limiting in tests. */ +const TEST_UNLIMITED_MAX = 1_000_000; + /** * Strict rate limiter for authentication endpoints (login, register). * Prevents brute-force credential attacks. diff --git a/src/repositories/BaseRepository.ts b/src/repositories/BaseRepository.ts index 84c935c..44c4eda 100644 --- a/src/repositories/BaseRepository.ts +++ b/src/repositories/BaseRepository.ts @@ -75,13 +75,16 @@ export abstract class BaseRepository implements IRepository< async createMany(data: Partial[], options?: WriteOptions): Promise { if (data.length === 0) return []; - const inserted = await this.model.insertMany(data, { + // With `rawResult: false` the driver resolves to the hydrated documents + // themselves; typing the promise generically keeps that contract explicit + // without an unsafe cast. + const inserted = (await this.model.insertMany(data, { session: options?.session, ordered: options?.ordered ?? true, rawResult: false, - }); + })) as unknown[]; - return inserted as unknown as T[]; + return inserted.filter((doc): doc is T => doc !== null && typeof doc === 'object'); } async findById(id: string, options?: ReadOptions): Promise { diff --git a/src/repositories/DeliveryRepository.ts b/src/repositories/DeliveryRepository.ts index 4bf4d4d..aec6fc5 100644 --- a/src/repositories/DeliveryRepository.ts +++ b/src/repositories/DeliveryRepository.ts @@ -64,7 +64,9 @@ export class DeliveryRepository extends BaseRepository { return new Set( found - .map((doc) => (doc as unknown as { trackingNumber?: string }).trackingNumber) + // `IDelivery` already declares `trackingNumber?: string`; the guard + // narrows the optional field instead of force-casting each document. + .map((doc) => doc.trackingNumber) .filter((value): value is string => typeof value === 'string'), ); } diff --git a/src/repositories/NotificationRepository.ts b/src/repositories/NotificationRepository.ts index aed9e93..465a6f0 100644 --- a/src/repositories/NotificationRepository.ts +++ b/src/repositories/NotificationRepository.ts @@ -1,4 +1,4 @@ -import { Types } from 'mongoose'; +import { Types, FilterQuery, SortOrder } from 'mongoose'; import Notification, { INotification } from '../models/Notification'; import { BaseRepository } from './BaseRepository'; import { Page } from './types'; @@ -17,11 +17,13 @@ export class NotificationRepository extends BaseRepository { /** One page of a user's notification history, newest first. */ async listForUser( - userId: string | Types.ObjectId, + _userId: string | Types.ObjectId, + filter: FilterQuery, page: number, limit: number, + sort: Record = { createdAt: -1 }, ): Promise> { - return this.paginate({ user: userId }, page, limit, { sort: { createdAt: -1 } }); + return this.paginate(filter, page, limit, { sort }); } /** Every notification recorded for a delivery, newest first. */ diff --git a/src/repositories/types.ts b/src/repositories/types.ts index dfed4e4..bada221 100644 --- a/src/repositories/types.ts +++ b/src/repositories/types.ts @@ -1,4 +1,11 @@ -import { ClientSession, FilterQuery, ProjectionType, QueryOptions, UpdateQuery } from 'mongoose'; +import { + ClientSession, + FilterQuery, + ProjectionType, + QueryOptions, + SortOrder, + UpdateQuery, +} from 'mongoose'; /** * Options accepted by every read operation on a repository. @@ -10,8 +17,8 @@ import { ClientSession, FilterQuery, ProjectionType, QueryOptions, UpdateQuery } export interface ReadOptions { /** Field selection, e.g. `'+password'` or `{ password: 1 }`. */ projection?: ProjectionType; - /** Sort specification, e.g. `{ createdAt: -1 }`. */ - sort?: Record; + /** Sort specification, e.g. `{ createdAt: -1 }` or `{ lastModified: 'asc' }`. */ + sort?: Record; /** Number of documents to skip (pagination offset). */ skip?: number; /** Maximum number of documents to return. */ diff --git a/src/routes/delivery.routes.ts b/src/routes/delivery.routes.ts index 71986a2..72b59cc 100644 --- a/src/routes/delivery.routes.ts +++ b/src/routes/delivery.routes.ts @@ -12,6 +12,19 @@ import requireRole from '../middleware/requireRole'; import { UserRole } from '../interfaces/IUser'; import { estimateFeeSchema } from '../validators/pricingValidator'; import { pricingController } from '../controllers/pricingController'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; + +/** Shared query contract for the delivery list endpoints (docs/query-contract.md). */ +const deliveryListQuery = buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + status: 'string', + driver: 'string', + }, + searchableFields: ['trackingNumber', 'customer.name', 'customer.phone'], + defaultSort: { createdAt: -1 }, + defaultLimit: 10, +}); const router = Router(); @@ -19,7 +32,7 @@ const router = Router(); router.post( '/fee-estimate', authenticate, - validateRequest({ body: estimateFeeSchema }), + validate({ body: estimateFeeSchema }), pricingController.estimate.bind(pricingController), ); @@ -92,7 +105,7 @@ router.post( deliveryController.create.bind(deliveryController), ); -router.get('/', deliveryController.list.bind(deliveryController)); +router.get('/', deliveryListQuery, deliveryController.list.bind(deliveryController)); /** * @openapi @@ -119,7 +132,11 @@ router.get('/', deliveryController.list.bind(deliveryController)); * schema: * $ref: '#/components/schemas/DeliveryListResponse' */ -router.get('/archived', deliveryController.listArchived.bind(deliveryController)); +router.get( + '/archived', + deliveryListQuery, + deliveryController.listArchived.bind(deliveryController), +); /** * @openapi diff --git a/src/routes/deliveryRoutes.ts b/src/routes/deliveryRoutes.ts index d35622a..dce2138 100644 --- a/src/routes/deliveryRoutes.ts +++ b/src/routes/deliveryRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { deliveryController } from '../controllers/deliveryController'; +import { deliveryController } from '../controllers/delivery.controller'; const router = Router(); diff --git a/src/routes/disputeRoutes.ts b/src/routes/disputeRoutes.ts index 214ab35..6cf7c7f 100644 --- a/src/routes/disputeRoutes.ts +++ b/src/routes/disputeRoutes.ts @@ -3,6 +3,7 @@ import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import validate from '../middleware/validate'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; import { openDispute, getDispute, @@ -33,7 +34,23 @@ router.post('/', authenticate, validate({ body: createDisputeSchema }), openDisp * @access Admin only — the query is not scoped to the caller, so exposing it * to every authenticated user would leak other users' disputes. */ -router.get('/', authenticate, requireRole(UserRole.ADMIN), listDisputes); +router.get( + '/', + authenticate, + requireRole(UserRole.ADMIN), + buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + status: 'string', + reason: 'string', + raisedBy: 'string', + deliveryId: 'string', + }, + defaultSort: { createdAt: -1 }, + defaultLimit: 10, + }), + listDisputes, +); /** * @route GET /api/v1/disputes/:id diff --git a/src/routes/eventLogRoutes.ts b/src/routes/eventLogRoutes.ts index a3569ba..de9e411 100644 --- a/src/routes/eventLogRoutes.ts +++ b/src/routes/eventLogRoutes.ts @@ -3,6 +3,7 @@ import eventLogController from '../controllers/eventLogController'; import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; const router = Router(); @@ -12,10 +13,16 @@ router.use(authenticate); // Get last processed ledger sequence router.get('/last-processed', eventLogController.getLastProcessedLedger.bind(eventLogController)); -// Get unprocessed events +// Get unprocessed events (paginated) router.get( '/unprocessed', requireRole(UserRole.ADMIN, UserRole.ENTERPRISE), + buildQueryOptions({ + sortableFields: ['createdAt', 'ledgerSequence'], + filterableFields: {}, + defaultSort: { createdAt: 1 }, + defaultLimit: 20, + }), eventLogController.getUnprocessedEvents.bind(eventLogController), ); diff --git a/src/routes/fleetRoutes.ts b/src/routes/fleetRoutes.ts index 68c0924..232cb52 100644 --- a/src/routes/fleetRoutes.ts +++ b/src/routes/fleetRoutes.ts @@ -14,6 +14,18 @@ import { removeMember, } from '../controllers/fleetController'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; + +/** Shared query contract for the fleet list endpoint (docs/query-contract.md). */ +const fleetListQuery = buildQueryOptions({ + sortableFields: ['createdAt', 'name'], + filterableFields: { + isActive: 'boolean', + }, + searchableFields: ['name'], + defaultSort: { createdAt: -1 }, + defaultLimit: 10, +}); const router = Router(); @@ -32,7 +44,7 @@ router.post('/', requireRole(UserRole.ENTERPRISE), createFleet); * @desc Get all fleets (paginated) * @access Enterprise/Admin */ -router.get('/', requireRole(UserRole.ENTERPRISE), getAllFleets); +router.get('/', requireRole(UserRole.ENTERPRISE), fleetListQuery, getAllFleets); /** * @route GET /api/v1/fleets/:id diff --git a/src/routes/notificationRoutes.ts b/src/routes/notificationRoutes.ts index ed9b31b..9a47070 100644 --- a/src/routes/notificationRoutes.ts +++ b/src/routes/notificationRoutes.ts @@ -1,10 +1,10 @@ import { Router } from 'express'; import authenticate from '../middleware/authenticate'; import validate from '../middleware/validate'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; import { getPreferences, listNotifications, - listNotificationsSchema, registerDevice, registerDeviceSchema, unregisterDevice, @@ -26,7 +26,19 @@ router.use(authenticate); * @desc Paginated notification history for the authenticated user * @access Private */ -router.get('/', validate({ query: listNotificationsSchema }), listNotifications); +router.get( + '/', + buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + status: 'string', + event: 'string', + }, + defaultSort: { createdAt: -1 }, + defaultLimit: 20, + }), + listNotifications, +); /** * @route GET /api/v1/notifications/preferences diff --git a/src/routes/poolingRoutes.ts b/src/routes/poolingRoutes.ts index 9621acc..32cdd58 100644 --- a/src/routes/poolingRoutes.ts +++ b/src/routes/poolingRoutes.ts @@ -20,4 +20,4 @@ router.post('/create', authenticate, requireRole(UserRole.ADMIN), createPools); */ router.post('/assign', authenticate, requireRole(UserRole.ADMIN), poolAndAssign); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 6fce06e..610e5cf 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -5,6 +5,7 @@ import requireRole from '../middleware/requireRole'; import validate from '../middleware/validate'; import { updateWalletSchema } from '../validators/userValidator'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; const router = Router(); @@ -29,6 +30,16 @@ router.get( '/deleted', authMiddleware, requireRole(UserRole.ADMIN), + buildQueryOptions({ + sortableFields: ['deletedAt', 'createdAt'], + filterableFields: { + role: 'string', + status: 'string', + }, + searchableFields: ['email', 'firstName', 'lastName'], + defaultSort: { deletedAt: -1 }, + defaultLimit: 10, + }), userController.listDeletedUsers, ); diff --git a/src/routes/webhookRoutes.ts b/src/routes/webhookRoutes.ts index 4178ebd..9ea42de 100644 --- a/src/routes/webhookRoutes.ts +++ b/src/routes/webhookRoutes.ts @@ -12,6 +12,7 @@ import { rotateWebhookSecret, } from '../controllers/webhookController'; import { registerWebhookSchema, updateWebhookSchema } from '../validators/webhookValidator'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; const router = Router(); @@ -32,7 +33,18 @@ router.post('/', validate({ body: registerWebhookSchema }), registerWebhook); * @desc List the authenticated merchant's registered webhooks * @access Merchant (enterprise) or admin */ -router.get('/', listWebhooks); +router.get( + '/', + buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + isActive: 'boolean', + }, + defaultSort: { createdAt: -1 }, + defaultLimit: 20, + }), + listWebhooks, +); /** * @route GET /api/v1/webhooks/:id diff --git a/src/services/authService.ts b/src/services/authService.ts index 3473aa1..a1c410e 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,11 +1,51 @@ import jwt from 'jsonwebtoken'; import { StatusCodes } from 'http-status-codes'; import User from '../models/User'; -import { IAuthResponse, ILoginPayload, IUser } from '../interfaces/IUser'; +import { IAuthResponse, ILoginPayload, UserRole, UserStatus } from '../interfaces/IUser'; import AppError from '../utils/AppError'; import logger from '../config/logger'; import env from '../config/env'; +/** + * Public user DTO — the exact JSON shape consumers receive from auth and + * user-profile endpoints. Derived from real Mongoose documents (never cast), + * so schema drift surfaces as a compile error instead of a runtime mismatch. + */ +export interface UserDTO { + id: string; + email: string; + firstName: string; + lastName: string; + role: UserRole; + /** Account lifecycle state — consumers gate suspended/banned accounts on it. */ + status: UserStatus; +} + +/** + * Map a Mongoose user document to the public {@link UserDTO} shape. + * + * The compiler verifies each field against the document type; no `as unknown` + * escape hatch is involved. + */ +export function toUserDTO(user: { + /** Mongoose documents expose `id` as string; lean projections as unknown. */ + id?: unknown; + email: string; + firstName: string; + lastName: string; + role: UserRole; + status: UserStatus; +}): UserDTO { + return { + id: String(user.id), + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + status: user.status, + }; +} + class AuthService { /** * Authenticate a user with email and password, returning a JWT token. @@ -50,13 +90,7 @@ class AuthService { logger.info(`User ${email} logged in successfully`); return { - user: { - id: user.id as string, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - }, + user: toUserDTO(user), token, }; } @@ -102,7 +136,7 @@ class AuthService { lastName: string; email: string; password: string; - }): Promise> { + }): Promise { const existingUser = await User.findOne({ email: payload.email }); if (existingUser) { throw new AppError('Email is already in use', StatusCodes.CONFLICT); @@ -110,17 +144,33 @@ class AuthService { const user = await User.create(payload); - return { - id: user.id as string, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - } as unknown as Partial; + return toUserDTO(user); } - public async getUserById(id: string): Promise { - return User.findById(id).lean().exec() as unknown as IUser | null; + /** + * Fetch a user by id using an explicit projection of the fields the + * {@link UserDTO} contract exposes. The projection keeps the selected + * columns and the DTO in sync: removing a field from one surfaces as a + * type error in the other. + */ + public async getUserById(id: string): Promise { + const user = await User.findById(id) + .select(['email', 'firstName', 'lastName', 'role', 'status']) + .lean<{ + id?: unknown; + email: string; + firstName: string; + lastName: string; + role: UserRole; + status: UserStatus; + }>() + .exec(); + + if (!user) { + return null; + } + + return toUserDTO(user); } } diff --git a/src/services/delivery.service.ts b/src/services/delivery.service.ts index 595b7c6..44740df 100644 --- a/src/services/delivery.service.ts +++ b/src/services/delivery.service.ts @@ -1,4 +1,4 @@ -import { Types } from 'mongoose'; +import { Types, SortOrder } from 'mongoose'; import { Asset, validateAsset } from './currencyService'; import httpStatus from 'http-status-codes'; import Delivery, { IDelivery, DeliveryStatus, ILocation, IPackage } from '../models/Delivery'; @@ -43,13 +43,16 @@ export interface AssignDriverInput { driverId: string; } -export interface DeliveryFilter { - status?: DeliveryStatus; - driver?: string; - search?: string; +/** + * Filter for the delivery list endpoint. The whitelisted fields are supplied + * by the shared query middleware; the service only normalises them. + */ +export type DeliveryFilter = { + filter?: Record; page?: number; limit?: number; -} + sort?: Record; +}; export interface PaginatedResult { data: T[]; @@ -116,32 +119,17 @@ export class DeliveryService { } async list(filters: DeliveryFilter): Promise> { - const { status, driver, search, page = 1, limit = 10 } = filters; + const { filter, page = 1, limit = 10, sort = { createdAt: -1 } } = filters; const query: Record = { // Soft-deleted deliveries are only exposed via GET /deliveries/archived. isDeleted: { $ne: true }, + ...filter, }; - if (status) { - query.status = status; - } - - if (driver) { - query.driver = new Types.ObjectId(driver); - } - - if (search) { - query.$or = [ - { trackingNumber: { $regex: search, $options: 'i' } }, - { 'customer.name': { $regex: search, $options: 'i' } }, - { 'customer.phone': { $regex: search, $options: 'i' } }, - ]; - } - const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - Delivery.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(), + Delivery.find(query).sort(sort).skip(skip).limit(limit).exec(), Delivery.countDocuments(query).exec(), ]); @@ -207,12 +195,16 @@ export class DeliveryService { return delivery.restore(); } - async listArchived(page = 1, limit = 10): Promise> { + async listArchived( + page = 1, + limit = 10, + sort: Record = { deletedAt: -1 }, + ): Promise> { const skip = (page - 1) * limit; const [data, total] = await Promise.all([ Delivery.find({ isDeleted: true }) .setOptions({ includeDeleted: true }) - .sort({ deletedAt: -1 }) + .sort(sort) .skip(skip) .limit(limit) .exec(), diff --git a/src/services/deliveryService.ts b/src/services/deliveryService.ts index 94920e5..063f663 100644 --- a/src/services/deliveryService.ts +++ b/src/services/deliveryService.ts @@ -1,7 +1,10 @@ import { Delivery, DeliveryStatus } from '../models/Delivery'; -import { routingService, ETARequest } from './routingService'; +import { ETARequest } from './providers/routingProvider'; +import type { IRoutingProvider } from './providers/routingProvider'; +import { routingService } from './routingService'; import QRCode from 'qrcode'; import { generateQrToken } from '../utils/qrToken'; +import AppError from '../utils/AppError'; interface DeliveryETARequest { deliveryId: string; @@ -27,6 +30,12 @@ interface DeliveryETAResponse { } class DeliveryService { + private readonly routing: IRoutingProvider; + + constructor(routing: IRoutingProvider = routingService) { + this.routing = routing; + } + async calculateDeliveryETA(request: DeliveryETARequest): Promise { const delivery = await Delivery.findOne({ deliveryId: request.deliveryId }); @@ -50,7 +59,7 @@ class DeliveryService { travelMode: 'driving', }; - const etaResult = await routingService.calculateETA(routingRequest); + const etaResult = await this.routing.calculateETA(routingRequest); delivery.distance = etaResult.distance * 1000; delivery.estimatedDuration = etaResult.estimatedTime * 60; @@ -103,20 +112,17 @@ class DeliveryService { const delivery = await Delivery.findById(deliveryId).lean(); if (!delivery) { - const err = new Error('Delivery not found'); - (err as any).statusCode = 404; - throw err; + throw new AppError('Delivery not found', 404); } // Validate delivery is in a handoff-eligible status // Use EXACT status values from Delivery schema const eligibleStatuses = [DeliveryStatus.IN_PROGRESS]; if (!eligibleStatuses.includes(delivery.status as DeliveryStatus)) { - const err = new Error( + throw new AppError( `Delivery is not eligible for handoff. Current status: ${delivery.status}`, + 400, ); - (err as any).statusCode = 400; - throw err; } // Generate secure token diff --git a/src/services/disputeService.ts b/src/services/disputeService.ts index 2aa45e4..e7b07ba 100644 --- a/src/services/disputeService.ts +++ b/src/services/disputeService.ts @@ -1,5 +1,5 @@ import { StatusCodes } from 'http-status-codes'; -import mongoose from 'mongoose'; +import mongoose, { SortOrder } from 'mongoose'; import Dispute, { DisputeReason, DisputeStatus, IDispute } from '../models/Dispute'; import Delivery, { DeliveryStatus } from '../models/Delivery'; import { sorobanService } from '../blockchain/soroban.service'; @@ -100,43 +100,27 @@ export const getDisputeById = async (id: string): Promise => { return dispute; }; -export const getDisputes = async (filters: DisputeFilter) => { - const { status, raisedBy, deliveryId, reason, page = 1, limit = 10 } = filters; - const query: Record = {}; - - if (status) { - query.status = status; - } - - if (raisedBy) { - if (!mongoose.Types.ObjectId.isValid(raisedBy)) { - throw new AppError('Invalid raisedBy format.', StatusCodes.BAD_REQUEST); - } - query.raisedBy = raisedBy; - } +export const getDisputes = async ( + filters: Pick & { + /** Whitelisted filter produced by the shared query middleware. */ + filter?: Record; + sort?: Record; + }, +) => { + const { filter = {}, page = 1, limit = 10, sort = { createdAt: -1 } } = filters; + const query: Record = { ...filter }; - if (deliveryId) { - if (!mongoose.Types.ObjectId.isValid(deliveryId)) { - throw new AppError('Invalid deliveryId format.', StatusCodes.BAD_REQUEST); - } - query.deliveryId = deliveryId; + if (typeof query.raisedBy === 'string' && !mongoose.Types.ObjectId.isValid(query.raisedBy)) { + throw new AppError('Invalid raisedBy format.', StatusCodes.BAD_REQUEST); } - if (reason) { - if (!Object.values(DisputeReason).includes(reason)) { - throw new AppError('Invalid dispute reason.', StatusCodes.BAD_REQUEST); - } - query.reason = reason; + if (typeof query.deliveryId === 'string' && !mongoose.Types.ObjectId.isValid(query.deliveryId)) { + throw new AppError('Invalid deliveryId format.', StatusCodes.BAD_REQUEST); } const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - Dispute.find(query) - .populate(populateOptions) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .exec(), + Dispute.find(query).populate(populateOptions).sort(sort).skip(skip).limit(limit).exec(), Dispute.countDocuments(query).exec(), ]); diff --git a/src/services/eventLogService.ts b/src/services/eventLogService.ts index 0959573..aa6ba83 100644 --- a/src/services/eventLogService.ts +++ b/src/services/eventLogService.ts @@ -1,5 +1,6 @@ import EventLog, { IEventLog } from '../models/EventLog'; import logger from '../config/logger'; +import type { SortOrder } from 'mongoose'; export class EventLogService { /** @@ -56,12 +57,38 @@ export class EventLogService { /** * Get all unprocessed events */ - async getUnprocessedEvents(): Promise { + async getUnprocessedEvents( + page = 1, + limit = 20, + sort: Record = { createdAt: 1 }, + ): Promise<{ + data: IEventLog[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { try { - return await EventLog.find({ status: 'pending' }).sort({ createdAt: 1 }).limit(100); + const safePage = Math.max(1, page); + const safeLimit = Math.min(Math.max(1, limit), 100); + const query = { status: 'pending' }; + const skip = (safePage - 1) * safeLimit; + + const [data, total] = await Promise.all([ + EventLog.find(query).sort(sort).skip(skip).limit(safeLimit), + EventLog.countDocuments(query), + ]); + + return { + data, + total, + page: safePage, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }; } catch (error) { logger.error('Error fetching unprocessed events:', error); - return []; + return { data: [], total: 0, page, limit, totalPages: 0 }; } } diff --git a/src/services/indexerService.ts b/src/services/indexerService.ts index 987bb02..f18e6d6 100644 --- a/src/services/indexerService.ts +++ b/src/services/indexerService.ts @@ -1,8 +1,9 @@ import EventLog from '../models/EventLog'; import Delivery from '../models/Delivery'; -import { sorobanRpcClient } from '../config/stellar'; import logger from '../config/logger'; import { emitDeliveryStatusUpdated } from '../sockets'; +import type { ISorobanRpcClient } from './providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from './providers/adapters'; export interface IndexerStatusData { eventType: string; contractId: string; @@ -19,9 +20,15 @@ export interface DeliveryStatusUpdatedEvent { } export class IndexerService { + private readonly rpcClient: ISorobanRpcClient; + + constructor(rpcClient: ISorobanRpcClient = defaultSorobanRpcClient) { + this.rpcClient = rpcClient; + } + public async getIndexerStatus(): Promise { try { - const currentLedgerResponse = await sorobanRpcClient.getLatestLedger(); + const currentLedgerResponse = await this.rpcClient.getLatestLedger(); const currentLedger = currentLedgerResponse.sequence; const logs = await EventLog.find({}).lean(); return logs.map((log) => { diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 962c489..0bbabed 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -1,5 +1,5 @@ import httpStatus from 'http-status-codes'; -import { Types } from 'mongoose'; +import { Types, SortOrder, FilterQuery } from 'mongoose'; import logger from '../config/logger'; import { AppError } from '../utils/AppError'; import { Delivery, DeliveryStatus, IDelivery } from '../models/Delivery'; @@ -388,10 +388,31 @@ export class NotificationService { return preference; } - /** One page of a user's notification history. */ - async listForUser(userId: string, page = 1, limit = 20): Promise> { + /** + * One page of a user's notification history. + * + * `filter`/`sort` come from the shared query middleware whitelist; the + * user scoping is applied here so a client can never read another user's + * notifications, whatever the query string contains. + */ + async listForUser( + userId: string, + options: { + filter?: Record; + page?: number; + limit?: number; + sort?: Record; + } = {}, + ): Promise> { this.assertValidUserId(userId); - return this.notificationRepository.listForUser(userId, page, limit); + const { filter = {}, page = 1, limit = 20, sort = { createdAt: -1 } } = options; + return this.notificationRepository.listForUser( + userId, + { ...filter, user: userId } as FilterQuery, + page, + limit, + sort, + ); } /** Whether the configured push provider can currently send. */ diff --git a/src/services/poolingService.ts b/src/services/poolingService.ts index 83c0a10..15599c6 100644 --- a/src/services/poolingService.ts +++ b/src/services/poolingService.ts @@ -8,15 +8,13 @@ * Delivery / DriverLocation models. All data is read from MongoDB. */ -import { StatusCodes } from 'http-status-codes'; -import { Types } from 'mongoose'; import Delivery, { IDelivery, DeliveryStatus } from '../models/Delivery'; import { DriverLocation } from '../models/DriverLocation'; import { driverLocationService, NearbyDriver } from './driverLocationService'; import { deliveryService } from './delivery.service'; import { routingService } from './routingService'; +import type { IRoutingProvider } from './providers/routingProvider'; import { encodeGeohash } from '../utils/geohash'; -import AppError from '../utils/AppError'; import logger from '../config/logger'; import env from '../config/env'; @@ -65,9 +63,15 @@ export interface AssignPoolResult { // ─── Service ────────────────────────────────────────────────────────────────── export class PoolingService { + private readonly routing: IRoutingProvider; + private readonly DEFAULT_PROXIMITY_M = 2000; private readonly DEFAULT_MAX_POOL_SIZE = 5; + constructor(routing: IRoutingProvider = routingService) { + this.routing = routing; + } + /** * Find pending/funded deliveries and group them into proximity-based pools. */ @@ -86,9 +90,7 @@ export class PoolingService { // Optional geographic window if (options.center) { - const deg = options.radiusMeters - ? options.radiusMeters / 111_320 - : 0.05; // ~5.5 km default + const deg = options.radiusMeters ? options.radiusMeters / 111_320 : 0.05; // ~5.5 km default filter['pickupCoordinates.lat'] = { $gte: options.center.lat - deg, $lte: options.center.lat + deg, @@ -244,9 +246,7 @@ export class PoolingService { // ── Private helpers ──────────────────────────────────────────────────────── private async buildPool(deliveries: IDelivery[]): Promise { - const centroid = this.calculateCentroid( - deliveries.map((d) => d.pickupCoordinates!), - ); + const centroid = this.calculateCentroid(deliveries.map((d) => d.pickupCoordinates!)); const routeSequence = this.buildNearestNeighbourRoute(deliveries); @@ -256,7 +256,7 @@ export class PoolingService { for (let i = 0; i < routeSequence.length - 1; i++) { const from = routeSequence[i].coordinates; const to = routeSequence[i + 1].coordinates; - const eta = await routingService.calculateETA({ + const eta = await this.routing.calculateETA({ pickup: from, dropoff: to, travelMode: 'driving', @@ -277,10 +277,10 @@ export class PoolingService { private calculateCentroid(points: Coordinates[]): Coordinates { const n = points.length; - const sum = points.reduce( - (acc, p) => ({ lat: acc.lat + p.lat, lng: acc.lng + p.lng }), - { lat: 0, lng: 0 }, - ); + const sum = points.reduce((acc, p) => ({ lat: acc.lat + p.lat, lng: acc.lng + p.lng }), { + lat: 0, + lng: 0, + }); return { lat: sum.lat / n, lng: sum.lng / n }; } @@ -322,9 +322,7 @@ export class PoolingService { // Cannot drop off before picking up the same delivery if ( remaining[i].type === 'dropoff' && - !sequence.some( - (s) => s.deliveryId === remaining[i].deliveryId && s.type === 'pickup', - ) + !sequence.some((s) => s.deliveryId === remaining[i].deliveryId && s.type === 'pickup') ) { continue; } @@ -356,13 +354,11 @@ export class PoolingService { const lat1 = toRad(a.lat); const lat2 = toRad(b.lat); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); } } export const poolingService = new PoolingService(); -export default poolingService; \ No newline at end of file +export default poolingService; diff --git a/src/services/providers/adapters.ts b/src/services/providers/adapters.ts new file mode 100644 index 0000000..6af7cd7 --- /dev/null +++ b/src/services/providers/adapters.ts @@ -0,0 +1,212 @@ +/** + * Concrete adapters implementing the provider contracts in this directory by + * delegating to the real external SDKs (issue #222). + * + * Keeping the SDK wiring in one place means the rest of the codebase depends + * only on the interfaces (`IRoutingProvider`, `ISorobanRpcClient`, + * `IImagesStorage`), and unit tests can substitute fakes without network + * access or module mocking. + */ + +import axios from 'axios'; +import type { Transaction, FeeBumpTransaction, rpc as StellarRpc } from '@stellar/stellar-sdk'; +import { sorobanRpcClient } from '../../config/stellar'; +import type { Coordinates, ETARequest, ETAResponse, IRoutingProvider } from './routingProvider'; +import type { + ISorobanRpcClient, + PreparedTransaction, + SorobanGetEventsRequest, +} from './sorobanRpcClient'; + +// ─── Google Maps routing adapter ────────────────────────────────────────────── + +const GOOGLE_MAPS_DIRECTIONS_URL = 'https://maps.googleapis.com/maps/api/directions/json'; + +/** Median free-flow speed (km/h) used when no routing provider is configured. */ +const FALLBACK_SPEEDS_KMH: Record, number> = { + driving: 40, + walking: 5, + bicycling: 15, + transit: 25, +}; + +/** + * Google Maps Directions implementation of {@link IRoutingProvider}. + * + * When no API key is configured (or the API call fails) it degrades to a + * great-circle (Haversine) estimate so ETA endpoints keep working without + * external credentials. + */ +export class GoogleMapsRoutingProvider implements IRoutingProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(apiKey: string, baseUrl: string = GOOGLE_MAPS_DIRECTIONS_URL) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + async calculateETA(request: ETARequest): Promise { + try { + if (this.apiKey) { + return await this.calculateWithGoogleMaps(request); + } + return this.calculateWithHaversine(request); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to calculate ETA:', error); + throw new Error('Failed to calculate delivery ETA'); + } + } + + private async calculateWithGoogleMaps(request: ETARequest): Promise { + const { pickup, dropoff, travelMode = 'driving' } = request; + + const params = { + origin: `${pickup.lat},${pickup.lng}`, + destination: `${dropoff.lat},${dropoff.lng}`, + mode: travelMode, + key: this.apiKey, + units: 'metric', + }; + + const response = await axios.get(this.baseUrl, { params }); + + if (response.data.status !== 'OK') { + throw new Error(`Google Maps API error: ${response.data.status}`); + } + + const route = response.data.routes[0]; + const leg = route.legs[0]; + + return { + estimatedTime: Math.ceil(leg.duration.value / 60), + distance: leg.distance.value / 1000, + durationText: leg.duration.text, + distanceText: leg.distance.text, + route: { + distance: leg.distance.value, + duration: leg.duration.value, + distanceText: leg.distance.text, + durationText: leg.duration.text, + }, + }; + } + + private calculateWithHaversine(request: ETARequest): ETAResponse { + const { pickup, dropoff, travelMode = 'driving' } = request; + + const distance = this.calculateHaversineDistance(pickup, dropoff); + const distanceKm = distance / 1000; + + const speed = FALLBACK_SPEEDS_KMH[travelMode]; + const durationMinutes = (distanceKm / speed) * 60; + + return { + estimatedTime: Math.ceil(durationMinutes), + distance: Math.round(distanceKm * 100) / 100, + durationText: `${Math.ceil(durationMinutes)} mins`, + distanceText: `${Math.round(distanceKm * 100) / 100} km`, + route: { + distance, + duration: durationMinutes * 60, + distanceText: `${Math.round(distanceKm * 100) / 100} km`, + durationText: `${Math.ceil(durationMinutes)} mins`, + }, + }; + } + + /** + * Calculate the great-circle distance between two points using the Haversine + * formula. Handles edge cases including anti-meridian crossings (±180° + * longitude). + * + * @param point1 - First coordinate point + * @param point2 - Second coordinate point + * @returns Distance in meters + */ + private calculateHaversineDistance(point1: Coordinates, point2: Coordinates): number { + const R = 6371000; // Earth's radius in meters + + const lat1Rad = this.toRadians(point1.lat); + const lat2Rad = this.toRadians(point2.lat); + const dLatRad = this.toRadians(point2.lat - point1.lat); + + // Handle anti-meridian edge case: + // When longitude difference exceeds 180°, wrap around the shorter path + let lngDiff = point2.lng - point1.lng; + + // Normalize longitude difference to [-180, 180] + if (lngDiff > 180) { + lngDiff -= 360; + } else if (lngDiff < -180) { + lngDiff += 360; + } + + const dLngRad = this.toRadians(lngDiff); + + // Haversine formula + const a = + Math.sin(dLatRad / 2) * Math.sin(dLatRad / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(dLngRad / 2) * Math.sin(dLngRad / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRadians(degrees: number): number { + return degrees * (Math.PI / 180); + } +} + +// ─── Soroban RPC adapter ────────────────────────────────────────────────────── + +/** + * `@stellar/stellar-sdk` implementation of {@link ISorobanRpcClient}. + * + * A thin delegate over `rpc.Server`: every method maps 1:1 onto the SDK, so + * there is no behaviour to test here — the value is that consumers can accept + * the interface and tests can pass fakes instead. + */ +export class SdkSorobanRpcClient implements ISorobanRpcClient { + private readonly server: StellarRpc.Server; + + constructor(server: StellarRpc.Server = sorobanRpcClient) { + this.server = server; + } + + getAccount(address: string) { + return this.server.getAccount(address); + } + + getHealth() { + return this.server.getHealth(); + } + + getTransaction(hash: string) { + return this.server.getTransaction(hash); + } + + getEvents(request: SorobanGetEventsRequest) { + return this.server.getEvents(request); + } + + getLatestLedger() { + return this.server.getLatestLedger(); + } + + getNetwork() { + return this.server.getNetwork(); + } + + prepareTransaction(tx: Transaction | FeeBumpTransaction): Promise { + return this.server.prepareTransaction(tx); + } + + sendTransaction(tx: Transaction | FeeBumpTransaction) { + return this.server.sendTransaction(tx); + } +} + +/** Shared adapter instance wrapping the configured default `rpc.Server`. */ +export const defaultSorobanRpcClient: ISorobanRpcClient = new SdkSorobanRpcClient(); diff --git a/src/services/providers/imagesStorage.ts b/src/services/providers/imagesStorage.ts new file mode 100644 index 0000000..4b14c99 --- /dev/null +++ b/src/services/providers/imagesStorage.ts @@ -0,0 +1,20 @@ +/** + * Provider-agnostic images/file storage contract (issue #222). + * + * `storage.service.ts` already defines the `StorageDriver` abstraction with + * local-disk and S3 implementations. This module gives that contract its + * canonical, domain-facing name so consumers can depend on `IImagesStorage` + * (consistent with `IPushProvider`/`IRoutingProvider`/`ISorobanRpcClient`) + * and the driver can be registered in the DI container for test doubles. + */ + +import type { StorageDriver } from '../storage.service'; + +/** + * Contract for persisting uploaded images and retrieving their URLs. + * + * Functionally identical to `StorageDriver`; kept as a separate exported name + * so the issue's `IImagesStorage` terminology has a canonical home and new + * consumers can import the contract without reaching into driver internals. + */ +export type IImagesStorage = StorageDriver; diff --git a/src/services/providers/routingProvider.ts b/src/services/providers/routingProvider.ts new file mode 100644 index 0000000..4f1ae8e --- /dev/null +++ b/src/services/providers/routingProvider.ts @@ -0,0 +1,50 @@ +/** + * Provider-agnostic routing/directions contract. + * + * ETA-dependent services (delivery ETA, delivery pooling) depend only on this + * interface, so swapping Google Maps Directions for another routing provider + * (Mapbox, OSRM, …) is a configuration change rather than a rewrite of the + * business logic. It also lets unit tests inject deterministic fakes instead + * of calling the Google Maps API. + */ + +/** A geographic point in decimal degrees. */ +export interface Coordinates { + lat: number; + lng: number; +} + +/** Raw route metrics returned by the underlying directions provider. */ +export interface RouteInfo { + distance: number; + duration: number; + distanceText: string; + durationText: string; +} + +/** Input for an ETA calculation. */ +export interface ETARequest { + pickup: Coordinates; + dropoff: Coordinates; + travelMode?: 'driving' | 'walking' | 'bicycling' | 'transit'; +} + +/** Result of an ETA calculation. */ +export interface ETAResponse { + estimatedTime: number; + distance: number; + durationText: string; + distanceText: string; + route: RouteInfo; +} + +/** + * Contract for a directions/ETA provider. + * + * Implementations must degrade gracefully (e.g. a distance-based fallback) + * when the provider is not configured, and throw when the request cannot be + * served at all. + */ +export interface IRoutingProvider { + calculateETA(request: ETARequest): Promise; +} diff --git a/src/services/providers/sorobanRpcClient.ts b/src/services/providers/sorobanRpcClient.ts new file mode 100644 index 0000000..1bfc10f --- /dev/null +++ b/src/services/providers/sorobanRpcClient.ts @@ -0,0 +1,51 @@ +/** + * Provider-agnostic Soroban RPC contract. + * + * Services that talk to the Stellar network (stellarService, sorobanService, + * transactionService, indexerService, escrow handlers) depend only on this + * interface, so the concrete `rpc.Server` from `@stellar/stellar-sdk` can be + * swapped for a fake in unit tests — no network, no credentials, no module + * mocking. It mirrors the `IPushProvider` pattern used for push notifications. + * + * Method signatures intentionally match `rpc.Server` 1:1 (returning the SDK's + * own parsed response types), so the SDK adapter below is a trivial delegate + * and fakes remain structurally compatible with the real client. + */ + +import type { + Account, + FeeBumpTransaction, + Transaction, + rpc as StellarRpc, +} from '@stellar/stellar-sdk'; + +/** Request shape for contract-event queries (`rpc.Server.GetEventsRequest`). */ +export type SorobanGetEventsRequest = StellarRpc.Server.GetEventsRequest; + +/** + * Transaction returned by `prepareTransaction`. + * Derived from the SDK's own signature so it can never drift. + */ +export type PreparedTransaction = Awaited>; + +/** Methods consumed from the Soroban RPC node, extracted behind an interface. */ +export interface ISorobanRpcClient { + /** Fetch the current sequence/attributes of an on-network account. */ + getAccount(address: string): Promise; + /** Liveness probe of the RPC node. */ + getHealth(): Promise; + /** Fetch a transaction's terminal state by hash. */ + getTransaction(hash: string): Promise; + /** Query contract events emitted from a ledger onwards. */ + getEvents(request: SorobanGetEventsRequest): Promise; + /** Header metadata for the newest ledger on the network. */ + getLatestLedger(): Promise; + /** Metadata (passphrase, protocol version) of the connected network. */ + getNetwork(): Promise; + /** Simulate a transaction and attach footprint/fees/resource data. */ + prepareTransaction(tx: Transaction | FeeBumpTransaction): Promise; + /** Submit a signed transaction envelope to the network. */ + sendTransaction( + tx: Transaction | FeeBumpTransaction, + ): Promise; +} diff --git a/src/services/routingService.ts b/src/services/routingService.ts index 9ff8104..6d0a091 100644 --- a/src/services/routingService.ts +++ b/src/services/routingService.ts @@ -1,162 +1,36 @@ -import axios from 'axios'; -import env from '../config/env'; - -export interface Coordinates { - lat: number; - lng: number; -} - -export interface RouteInfo { - distance: number; - duration: number; - distanceText: string; - durationText: string; -} - -export interface ETARequest { - pickup: Coordinates; - dropoff: Coordinates; - travelMode?: 'driving' | 'walking' | 'bicycling' | 'transit'; -} - -export interface ETAResponse { - estimatedTime: number; - distance: number; - durationText: string; - distanceText: string; - route: RouteInfo; -} +/** + * Routing facade over the injectable {@link IRoutingProvider} (issue #222). + * + * The Google Maps implementation (plus the Haversine fallback) now lives in + * `providers/adapters.ts` behind the `IRoutingProvider` contract, so ETA + * consumers and unit tests can swap in a fake without touching this module. + * The exported types are re-exports of the shared contract to keep existing + * imports (`import { ETARequest } from './routingService'`) working. + */ -class RoutingService { - private readonly apiKey: string; - private readonly baseUrl: string; - - constructor() { - this.apiKey = env.GOOGLE_MAPS_API_KEY ?? ''; - this.baseUrl = 'https://maps.googleapis.com/maps/api/directions/json'; - - if (!this.apiKey) { - // eslint-disable-next-line no-console - console.warn('⚠️ Google Maps API key not configured. Using fallback calculation.'); - } +import env from '../config/env'; +import { GoogleMapsRoutingProvider } from './providers/adapters'; +import type { + Coordinates, + ETARequest, + ETAResponse, + IRoutingProvider, +} from './providers/routingProvider'; + +export type { Coordinates, ETARequest, ETAResponse }; +export { IRoutingProvider }; + +class RoutingService implements IRoutingProvider { + private readonly provider: IRoutingProvider; + + constructor( + provider: IRoutingProvider = new GoogleMapsRoutingProvider(env.GOOGLE_MAPS_API_KEY ?? ''), + ) { + this.provider = provider; } async calculateETA(request: ETARequest): Promise { - try { - if (this.apiKey) { - return await this.calculateWithGoogleMaps(request); - } - return this.calculateWithHaversine(request); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to calculate ETA:', error); - throw new Error('Failed to calculate delivery ETA'); - } - } - - private async calculateWithGoogleMaps(request: ETARequest): Promise { - const { pickup, dropoff, travelMode = 'driving' } = request; - - const params = { - origin: `${pickup.lat},${pickup.lng}`, - destination: `${dropoff.lat},${dropoff.lng}`, - mode: travelMode, - key: this.apiKey, - units: 'metric', - }; - - const response = await axios.get(this.baseUrl, { params }); - - if (response.data.status !== 'OK') { - throw new Error(`Google Maps API error: ${response.data.status}`); - } - - const route = response.data.routes[0]; - const leg = route.legs[0]; - - return { - estimatedTime: Math.ceil(leg.duration.value / 60), - distance: leg.distance.value / 1000, - durationText: leg.duration.text, - distanceText: leg.distance.text, - route: { - distance: leg.distance.value, - duration: leg.duration.value, - distanceText: leg.distance.text, - durationText: leg.duration.text, - }, - }; - } - - private calculateWithHaversine(request: ETARequest): ETAResponse { - const { pickup, dropoff, travelMode = 'driving' } = request; - - const distance = this.calculateHaversineDistance(pickup, dropoff); - const distanceKm = distance / 1000; - - const speeds: Record = { - driving: 40, - walking: 5, - bicycling: 15, - transit: 25, - }; - - const speed = speeds[travelMode] || 40; - const durationMinutes = (distanceKm / speed) * 60; - - return { - estimatedTime: Math.ceil(durationMinutes), - distance: Math.round(distanceKm * 100) / 100, - durationText: `${Math.ceil(durationMinutes)} mins`, - distanceText: `${Math.round(distanceKm * 100) / 100} km`, - route: { - distance: distance, - duration: durationMinutes * 60, - distanceText: `${Math.round(distanceKm * 100) / 100} km`, - durationText: `${Math.ceil(durationMinutes)} mins`, - }, - }; - } - - /** - * Calculate the great-circle distance between two points using the Haversine formula. - * Handles edge cases including anti-meridian crossings (±180° longitude). - * - * @param point1 - First coordinate point - * @param point2 - Second coordinate point - * @returns Distance in meters - */ - private calculateHaversineDistance(point1: Coordinates, point2: Coordinates): number { - const R = 6371000; // Earth's radius in meters - - const lat1Rad = this.toRadians(point1.lat); - const lat2Rad = this.toRadians(point2.lat); - const dLatRad = this.toRadians(point2.lat - point1.lat); - - // Handle anti-meridian edge case: - // When longitude difference exceeds 180°, wrap around the shorter path - let lngDiff = point2.lng - point1.lng; - - // Normalize longitude difference to [-180, 180] - if (lngDiff > 180) { - lngDiff -= 360; - } else if (lngDiff < -180) { - lngDiff += 360; - } - - const dLngRad = this.toRadians(lngDiff); - - // Haversine formula - const a = - Math.sin(dLatRad / 2) * Math.sin(dLatRad / 2) + - Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(dLngRad / 2) * Math.sin(dLngRad / 2); - - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - } - - private toRadians(degrees: number): number { - return degrees * (Math.PI / 180); + return this.provider.calculateETA(request); } } diff --git a/src/services/stellarService.ts b/src/services/stellarService.ts index e55f401..42ae513 100644 --- a/src/services/stellarService.ts +++ b/src/services/stellarService.ts @@ -10,7 +10,9 @@ import { xdr, } from '@stellar/stellar-sdk'; import { StatusCodes } from 'http-status-codes'; -import { sorobanRpcClient, stellarConfig } from '../config/stellar'; +import { stellarConfig } from '../config/stellar'; +import type { ISorobanRpcClient } from './providers/sorobanRpcClient'; +import { SdkSorobanRpcClient } from './providers/adapters'; import { deliveryService } from './delivery.service'; import { IDelivery } from '../models/Delivery'; import { toStroops } from '../utils/stroops'; @@ -160,6 +162,11 @@ export function isTransientRpcError(error: unknown, kind: AttemptFailureKind = ' ); } +/** Response variant accepted by {@link isBadSeqError} (parsed or raw). */ +type SendTransactionResult = + | StellarRpc.Api.SendTransactionResponse + | StellarRpc.Api.RawSendTransactionResponse; + /** * Inspect a `SendTransactionResponse` or a thrown error and decide whether it * represents a sequence-number mismatch (`tx_bad_seq`). @@ -173,27 +180,31 @@ export function isTransientRpcError(error: unknown, kind: AttemptFailureKind = ' * * We check both paths so the guard is robust to SDK / node differences. */ -function isBadSeqError(responseOrError: StellarRpc.Api.SendTransactionResponse | unknown): boolean { +function isBadSeqError(responseOrError: SendTransactionResult | Error): boolean { // Path 1: resolved response object if ( typeof responseOrError === 'object' && responseOrError !== null && 'status' in responseOrError ) { - const resp = responseOrError as StellarRpc.Api.SendTransactionResponse; + const resp: SendTransactionResult = responseOrError; if (resp.status !== 'ERROR') return false; // Try to decode the error result to confirm it's specifically txBAD_SEQ. - // SDK v13 exposes the decoded `errorResult` (the raw `errorResultXdr` - // string only exists on the raw response variant). + // SDK v13 exposes the decoded `errorResult` on the parsed response, while + // the raw transport variant only carries the base64 `errorResultXdr`. const rawResultXdr = (() => { - const withRaw = resp as unknown as { errorResultXdr?: string }; - if (withRaw.errorResultXdr) return withRaw.errorResultXdr; - try { - return resp.errorResult?.toXDR('base64'); - } catch { - return undefined; + if ('errorResultXdr' in resp && typeof resp.errorResultXdr === 'string') { + return resp.errorResultXdr; + } + if ('errorResult' in resp && resp.errorResult) { + try { + return resp.errorResult.toXDR('base64'); + } catch { + return undefined; + } } + return undefined; })(); if (rawResultXdr) { @@ -247,7 +258,7 @@ function isBadSeqError(responseOrError: StellarRpc.Api.SendTransactionResponse | * Retry attempts are capped by `STELLAR_BAD_SEQ_MAX_RETRIES` (default 3). */ export class StellarService { - private readonly client: StellarRpc.Server; + private readonly client: ISorobanRpcClient; private readonly badSeqMaxRetries: number; private readonly rpcMaxAttempts: number; private readonly rpcBaseDelayMs: number; @@ -255,7 +266,7 @@ export class StellarService { private readonly rpcJitterRatio: number; private readonly rpcTimeoutMs: number; - constructor(client: StellarRpc.Server = sorobanRpcClient) { + constructor(client: ISorobanRpcClient = new SdkSorobanRpcClient()) { this.client = client; this.badSeqMaxRetries = env.STELLAR_BAD_SEQ_MAX_RETRIES; this.rpcMaxAttempts = env.SOROBAN_RPC_MAX_RETRIES; @@ -408,8 +419,10 @@ export class StellarService { // ── Other error ─────────────────────────────────────────────────────── const errXdr = - response.errorResult?.toXDR('base64') ?? - (response as unknown as { errorResultXdr?: string }).errorResultXdr ?? + ('errorResult' in response && response.errorResult + ? response.errorResult.toXDR('base64') + : undefined) ?? + ('errorResultXdr' in response ? response.errorResultXdr : undefined) ?? '(no XDR)'; logger.error( `[StellarService] Submission failed — status=${response.status} ` + @@ -480,7 +493,7 @@ export class StellarService { // ── Private helpers ───────────────────────────────────────────────────────── /** Send a signed XDR to the RPC node. Never throws — returns the response. */ - private async send(signedXdr: string): Promise { + private async send(signedXdr: string): Promise { try { // Deserialise the signed envelope XDR into a Transaction object. const tx = TransactionBuilder.fromXDR( @@ -507,22 +520,22 @@ export class StellarService { ); } - // If the SDK itself throws with bad-seq language surface it as a - // synthetic response object so the caller's isBadSeqError check works. + // If the SDK itself throws with bad-seq language, surface a typed raw + // error response so the caller's isBadSeqError check works without + // falling back to message matching on the submit path. if ( message.toLowerCase().includes('tx_bad_seq') || message.toLowerCase().includes('txbadseq') ) { logger.warn(`[StellarService] sendTransaction threw bad-seq: ${message}`); - return { + const rawResponse: StellarRpc.Api.RawSendTransactionResponse = { status: 'ERROR', hash: '', - errorResultXdr: '', - networkPassphrase: stellarConfig.networkPassphrase, - latestCheckpointLedger: 0, latestLedger: 0, - latestLedgerCloseTime: BigInt(0), - } as unknown as StellarRpc.Api.SendTransactionResponse; + latestLedgerCloseTime: 0, + errorResultXdr: '', + }; + return rawResponse; } logger.error(`[StellarService] sendTransaction threw unexpectedly: ${message}`); @@ -570,11 +583,8 @@ export class StellarService { } if (txResponse.status === StellarRpc.Api.GetTransactionStatus.FAILED) { - // SDK v13 returns the decoded TransactionResult on failure responses. - const resultXdr = - txResponse.resultXdr?.toXDR('base64') ?? - (txResponse as unknown as { resultXdr?: string }).resultXdr ?? - '(no XDR)'; + // The FAILED variant always carries the decoded TransactionResult. + const resultXdr = txResponse.resultXdr.toXDR('base64'); logger.error(`[StellarService] Transaction FAILED — hash=${hash} resultXdr=${resultXdr}`); throw new AppError( `Transaction was submitted but failed on-chain. Result XDR: ${resultXdr}`, diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 4a22b51..8c5324c 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -6,11 +6,10 @@ import { Transaction, TransactionBuilder, nativeToScVal, - rpc as StellarRpc, } from '@stellar/stellar-sdk'; import { StatusCodes } from 'http-status-codes'; import CircuitBreaker from 'opossum'; -import { sorobanRpcClient, stellarConfig } from '../config/stellar'; +import { stellarConfig } from '../config/stellar'; import { deliveryService } from './delivery.service'; import { DeliveryStatus, IDelivery } from '../models/Delivery'; import { fromStroops, toStroops } from '../utils/stroops'; @@ -18,6 +17,8 @@ import AppError from '../utils/AppError'; import logger from '../config/logger'; import env from '../config/env'; import { createCircuitBreaker, fireWithBreaker } from '../utils/circuitBreaker'; +import type { ISorobanRpcClient } from './providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from './providers/adapters'; /** Input accepted by {@link TransactionService.buildEscrowLockXdr}. */ export interface EscrowLockXdrInput { @@ -77,10 +78,10 @@ const NON_LOCKABLE_STATUSES: ReadonlySet = new Set([ * without waiting for a TCP timeout. */ export class TransactionService { - private readonly client: StellarRpc.Server; + private readonly client: ISorobanRpcClient; private readonly breaker: CircuitBreaker<[() => Promise], unknown>; - constructor(client: StellarRpc.Server = sorobanRpcClient) { + constructor(client: ISorobanRpcClient = defaultSorobanRpcClient) { this.client = client; // A dedicated breaker for transaction-building RPC calls. We use a diff --git a/src/services/userService.ts b/src/services/userService.ts index 35868f5..f0d42f8 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,8 +1,8 @@ import { StatusCodes } from 'http-status-codes'; -import mongoose from 'mongoose'; +import mongoose, { SortOrder } from 'mongoose'; import User from '../models/User'; import DriverProfile from '../models/DriverProfile'; -import Delivery, { IDelivery } from '../models/Delivery'; +import Delivery from '../models/Delivery'; import { IUser, UserRole, UserStatus } from '../interfaces/IUser'; import { AppError } from '../utils/AppError'; import logger from '../config/logger'; @@ -31,6 +31,8 @@ export interface UserFilter { page?: number; limit?: number; includeDeleted?: boolean; + /** Whitelisted filter produced by the shared query middleware. */ + filter?: Record; } export interface PaginatedUserResult { @@ -183,12 +185,11 @@ export class UserService { let driverProfileDeleted = false; let deliveriesDeleted = 0; - // Cascade: soft-delete driver profile + // Cascade: soft-delete driver profile. `softDelete` is declared on the + // schema's methods, so the hydrated document type already carries it. const driverProfile = await DriverProfile.findOne({ userId: id }); if (driverProfile) { - await ( - driverProfile as unknown as { softDelete(userId?: string): Promise } - ).softDelete(userId); + await driverProfile.softDelete(userId); driverProfileDeleted = true; } @@ -207,7 +208,7 @@ export class UserService { .setOptions({ includeDeleted: true }) .exec(); for (const delivery of deliveries) { - await (delivery as unknown as IDelivery).softDelete(userId); + await delivery.softDelete(userId); deliveriesDeleted++; } @@ -257,30 +258,18 @@ export class UserService { /** * List soft-deleted users. */ - async getDeletedUsers(filters: Omit): Promise { - const { page = 1, limit = 10, ...rest } = filters; + async getDeletedUsers( + filters: Omit & { + sort?: Record; + }, + ): Promise { + const { page = 1, limit = 10, filter, sort = { deletedAt: -1 } } = filters; - const query: Record = { isDeleted: true }; - - if (rest.role) { - query.role = rest.role; - } - - if (rest.status) { - query.status = rest.status; - } - - if (rest.search) { - query.$or = [ - { email: { $regex: rest.search, $options: 'i' } }, - { firstName: { $regex: rest.search, $options: 'i' } }, - { lastName: { $regex: rest.search, $options: 'i' } }, - ]; - } + const query: Record = { isDeleted: true, ...filter }; const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - User.find(query).sort({ deletedAt: -1 }).skip(skip).limit(limit).exec(), + User.find(query).sort(sort).skip(skip).limit(limit).exec(), User.countDocuments(query).exec(), ]); diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index 39b5073..57b1b70 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -27,7 +27,7 @@ import crypto from 'crypto'; import axios from 'axios'; import { StatusCodes } from 'http-status-codes'; -import { Types } from 'mongoose'; +import { Types, SortOrder } from 'mongoose'; import { WebhookSubscription, IWebhookSubscription, @@ -111,9 +111,44 @@ export class WebhookService { } /** List a merchant's registered webhooks, newest first. Secret is never included. */ - async listForMerchant(merchantId: string): Promise { + /** + * One page of the merchant's registered webhooks. + * + * `filter`/`sort` come from the shared query middleware whitelist; scoping + * to the authenticated merchant happens here. + */ + async listForMerchant( + merchantId: string, + options: { + filter?: Record; + page?: number; + limit?: number; + sort?: Record; + } = {}, + ): Promise<{ + data: IWebhookSubscription[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { this.assertValidObjectId(merchantId, 'merchantId'); - return WebhookSubscription.find({ merchantId }).sort({ createdAt: -1 }); + const { filter = {}, page = 1, limit = 20, sort = { createdAt: -1 } } = options; + + const query = { ...filter, merchantId }; + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + WebhookSubscription.find(query).sort(sort).skip(skip).limit(limit), + WebhookSubscription.countDocuments(query), + ]); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } /** Fetch one webhook, scoped to its owning merchant. */ diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts index 9bb37a6..61507f7 100644 --- a/tests/delivery.test.ts +++ b/tests/delivery.test.ts @@ -135,7 +135,7 @@ describe('Delivery API — GET /api/v1/deliveries', () => { expect(res.status).toBe(200); expect(res.body.data.deliveries).toHaveLength(2); - expect(res.body.data.meta.total).toBe(5); + expect(res.body.data.meta.totalItems).toBe(5); expect(res.body.data.meta.totalPages).toBe(3); }); diff --git a/tests/di.container.test.ts b/tests/di.container.test.ts index 0145e7e..c4a3324 100644 --- a/tests/di.container.test.ts +++ b/tests/di.container.test.ts @@ -161,6 +161,9 @@ describe('DI Container', () => { TOKENS.indexerService, TOKENS.monitorService, TOKENS.idempotencyService, + TOKENS.routingProvider, + TOKENS.sorobanRpcClient, + TOKENS.imagesStorage, ]; services.forEach((token) => { diff --git a/tests/dispute.test.ts b/tests/dispute.test.ts index eee71f4..5082538 100644 --- a/tests/dispute.test.ts +++ b/tests/dispute.test.ts @@ -422,9 +422,9 @@ describe('GET /api/v1/disputes', () => { const res = await request(app).get('/api/v1/disputes').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); - expect(Array.isArray(res.body.data)).toBe(true); - expect(res.body.data.length).toBeGreaterThanOrEqual(1); - expect(res.body.meta.total).toBeGreaterThanOrEqual(1); + expect(Array.isArray(res.body.data.disputes)).toBe(true); + expect(res.body.data.disputes.length).toBeGreaterThanOrEqual(1); + expect(res.body.data.meta.totalItems).toBeGreaterThanOrEqual(1); }); it('filters disputes by status', async () => { diff --git a/tests/disputeRoutes.test.ts b/tests/disputeRoutes.test.ts index 831f0ce..ca94ed7 100644 --- a/tests/disputeRoutes.test.ts +++ b/tests/disputeRoutes.test.ts @@ -88,8 +88,9 @@ describe('GET /api/v1/disputes', () => { const res = await request(app).get('/api/v1/disputes').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(1); - expect(res.body.data[0].disputeId).toBe('dispute-1'); + expect(res.body.data.disputes).toHaveLength(1); + expect(res.body.data.disputes[0].disputeId).toBe('dispute-1'); + expect(res.body.data.meta.totalItems).toBe(1); }); }); diff --git a/tests/integration/queryContract.test.ts b/tests/integration/queryContract.test.ts new file mode 100644 index 0000000..5db48c8 --- /dev/null +++ b/tests/integration/queryContract.test.ts @@ -0,0 +1,381 @@ +/** + * Integration tests: shared query contract across list endpoints (issue #221). + * + * Every migrated list endpoint must accept `page`, `limit`, `sort` and + * whitelisted filters, and return the exact same pagination meta structure + * built by `buildPaginationMeta` (see docs/query-contract.md). + * + * Real MongoDB via MongoMemoryServer; the app is booted via supertest so the + * actual middleware chain (auth → role → buildQueryOptions → controller) runs. + */ + +import request from 'supertest'; +import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Express } from 'express'; + +import { Delivery } from '../../src/models/Delivery'; +import User from '../../src/models/User'; +import Dispute from '../../src/models/Dispute'; +import Fleet from '../../src/models/Fleet'; +import Notification from '../../src/models/Notification'; +import { WebhookSubscription } from '../../src/models/WebhookSubscription'; +import EventLog from '../../src/models/EventLog'; +import { NotificationStatus } from '../../src/models/Notification'; +import { DisputeStatus, DisputeReason } from '../../src/models/Dispute'; +import { UserRole } from '../../src/interfaces/IUser'; +import env from '../../src/config/env'; + +jest.mock('../../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../../src/config/database', () => ({ + connectDatabase: jest.fn(), +})); + +/** The meta shape every list endpoint must return. */ +const META_KEYS = [ + 'totalItems', + 'totalPages', + 'currentPage', + 'limit', + 'hasNextPage', + 'hasPreviousPage', + 'nextPage', + 'previousPage', +] as const; + +let app: Express; +let mongoServer: MongoMemoryServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGODB_URI = mongoServer.getUri(); + + const mod = await import('../../src/app'); + app = mod.default; + await mongoose.connect(mongoServer.getUri()); +}); + +afterEach(async () => { + const collections = mongoose.connection.collections; + for (const key of Object.keys(collections)) { + await collections[key].deleteMany({}); + } +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +// ─── Auth helpers ───────────────────────────────────────────────────────────── + +/** + * Sign a token the way the app expects. `authenticate` loads the User from + * the database by `userId`, so tests must create real users first; routes + * guarded by the JWT-only `authMiddleware` additionally read `role` from the + * token payload. Tokens are signed with the already-parsed `env.JWT_SECRET` + * so they verify no matter what secret the runner provided. + */ +const signToken = (userId: string, role?: string): string => + jwt.sign(role ? { userId, role } : { userId }, env.JWT_SECRET, { expiresIn: '1h' }); + +let userSeq = 0; + +const createUser = async (role: UserRole): Promise> => + User.create({ + email: `qc-${role}-${++userSeq}@swiftchain.test`, + password: 'SecretPass123!', + firstName: 'QC', + lastName: role, + role, + status: 'active', + }); + +/** Valid 56-char Stellar address; the suffix keeps fleets below the unique index. */ +const treasury = (i: number): string => `G${'A'.repeat(51)}${String(i).padStart(4, '0')}`; + +/** Assert a pagination payload carries the exact shared meta contract. */ +function expectSharedMeta(meta: unknown): void { + expect(meta).toBeDefined(); + META_KEYS.forEach((key) => { + expect(meta).toHaveProperty(key); + }); +} + +// ─── Deliveries ─────────────────────────────────────────────────────────────── + +describe('GET /api/v1/deliveries', () => { + it('applies page/limit and returns the shared meta', async () => { + for (let i = 0; i < 5; i++) { + await Delivery.create({ + trackingNumber: `QC-00${i}`, + customer: { name: 'QC Customer', phone: '+1000000000' }, + pickup: { address: 'a', city: 'c' }, + dropoff: { address: 'b', city: 'c' }, + package: { description: 'box', weight: 1 }, + status: 'pending', + }); + } + + const res = await request(app).get('/api/v1/deliveries?page=2&limit=2'); + + expect(res.status).toBe(200); + expect(res.body.data.deliveries).toHaveLength(2); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ + totalItems: 5, + totalPages: 3, + currentPage: 2, + limit: 2, + hasNextPage: true, + hasPreviousPage: true, + nextPage: 3, + previousPage: 1, + }); + + const last = await request(app).get('/api/v1/deliveries?page=3&limit=2'); + expect(last.status).toBe(200); + expect(last.body.data.meta).toMatchObject({ + currentPage: 3, + hasNextPage: false, + nextPage: null, + previousPage: 2, + }); + }); + + it('supports whitelisted filters, search and sort, rejecting unknown sort fields', async () => { + await Delivery.create({ + trackingNumber: 'QC-FILTER', + customer: { name: 'Filter Me', phone: '+1000000001' }, + pickup: { address: 'a', city: 'c' }, + dropoff: { address: 'b', city: 'c' }, + package: { description: 'box', weight: 1 }, + status: 'pending', + }); + await Delivery.create({ + trackingNumber: 'QC-OTHER', + customer: { name: 'Filter Me', phone: '+1000000001' }, + pickup: { address: 'a', city: 'c' }, + dropoff: { address: 'b', city: 'c' }, + package: { description: 'box', weight: 1 }, + status: 'assigned', + }); + + const filtered = await request(app).get('/api/v1/deliveries?status=pending'); + expect(filtered.status).toBe(200); + expect(filtered.body.data.deliveries).toHaveLength(1); + expect(filtered.body.data.deliveries[0].status).toBe('pending'); + + const searched = await request(app).get('/api/v1/deliveries?search=QC-FILT'); + expect(searched.status).toBe(200); + expect(searched.body.data.deliveries).toHaveLength(1); + + const sorted = await request(app).get('/api/v1/deliveries?sort=-createdAt&limit=1'); + expect(sorted.status).toBe(200); + expectSharedMeta(sorted.body.data.meta); + + const badSort = await request(app).get('/api/v1/deliveries?sort=notAField'); + expect(badSort.status).toBe(400); + + const badPage = await request(app).get('/api/v1/deliveries?page=0'); + expect(badPage.status).toBe(400); + }); +}); + +// ─── Disputes (admin-only list) ─────────────────────────────────────────────── + +describe('GET /api/v1/disputes', () => { + it('paginates, filters and returns the shared meta', async () => { + const admin = await createUser(UserRole.ADMIN); + + for (let i = 0; i < 3; i++) { + await Dispute.create({ + disputeId: `qcd-${i}`, + deliveryId: `qd-${i}`, + openedBy: 'GABC', + raisedBy: 'GABC', + reason: DisputeReason.OTHER, + description: 'QC dispute', + status: DisputeStatus.OPEN, + openedLedger: 10 + i, + }); + } + + const res = await request(app) + .get('/api/v1/disputes?page=1&limit=2&status=open') + .set('Authorization', `Bearer ${signToken(admin._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.disputes).toHaveLength(2); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 3, totalPages: 2, currentPage: 1 }); + }); + + it('rejects a non-whitelisted filter operator', async () => { + const admin = await createUser(UserRole.ADMIN); + + const res = await request(app) + .get('/api/v1/disputes?status[regex]=open') + .set('Authorization', `Bearer ${signToken(admin._id.toString())}`); + + expect(res.status).toBe(400); + }); +}); + +// ─── Users (admin-only deleted list) ────────────────────────────────────────── + +describe('GET /api/v1/users/deleted', () => { + it('paginates soft-deleted users with the shared meta', async () => { + // This route uses the JWT-only authMiddleware, so the role must travel + // inside the token payload rather than being loaded from the database. + const admin = await createUser(UserRole.ADMIN); + + for (let i = 0; i < 3; i++) { + await User.create({ + email: `qc-del-${i}-${userSeq}@swiftchain.test`, + password: 'SecretPass123!', + firstName: 'QC', + lastName: `Deleted${i}`, + role: 'user', + status: 'active', + isDeleted: true, + deletedAt: new Date(), + }); + } + + const res = await request(app) + .get('/api/v1/users/deleted?page=1&limit=2') + .set('Authorization', `Bearer ${signToken(admin._id.toString(), UserRole.ADMIN)}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + // The users list keeps its legacy top-level `pagination` key. + expectSharedMeta(res.body.pagination); + expect(res.body.pagination).toMatchObject({ totalItems: 3, totalPages: 2, limit: 2 }); + }); +}); + +// ─── Fleets ─────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/fleets', () => { + it('returns the shared meta instead of the legacy `pages` key', async () => { + const owner = await createUser(UserRole.ENTERPRISE); + + for (let i = 0; i < 2; i++) { + await Fleet.create({ + name: `QC Fleet ${i}`, + treasuryAddress: treasury(i), + ownerId: owner._id, + members: [], + businessMetadata: { + companyName: 'QC Logistics', + contactEmail: 'fleet@qc.test', + }, + isActive: true, + }); + } + + const res = await request(app) + .get('/api/v1/fleets?limit=1&search=Fleet') + .set('Authorization', `Bearer ${signToken(owner._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.fleets).toHaveLength(1); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 2, totalPages: 2 }); + expect(res.body.data.meta).not.toHaveProperty('pages'); + }); +}); + +// ─── Notifications ──────────────────────────────────────────────────────────── + +describe('GET /api/v1/notifications', () => { + it('paginates the user-scoped history with the shared meta', async () => { + const user = await createUser(UserRole.USER); + + for (let i = 0; i < 3; i++) { + await Notification.create({ + user: user._id, + event: 'delivery.assigned', + channel: 'push', + title: `QC ${i}`, + body: 'QC body', + data: {}, + status: NotificationStatus.SENT, + acceptedCount: 1, + rejectedCount: 0, + }); + } + + const res = await request(app) + .get('/api/v1/notifications?page=2&limit=2&status=sent') + .set('Authorization', `Bearer ${signToken(user._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expectSharedMeta(res.body.meta); + expect(res.body.meta).toMatchObject({ totalItems: 3, totalPages: 2, currentPage: 2 }); + }); +}); + +// ─── Webhooks ───────────────────────────────────────────────────────────────── + +describe('GET /api/v1/webhooks', () => { + it('paginates merchant webhooks and returns the shared meta', async () => { + const merchant = await createUser(UserRole.ENTERPRISE); + + for (let i = 0; i < 2; i++) { + await WebhookSubscription.create({ + merchantId: merchant._id, + url: `https://example.com/hook-${i}`, + secret: 's3cret-value', + events: ['delivery.assigned'], + isActive: true, + }); + } + + const res = await request(app) + .get('/api/v1/webhooks?limit=1&isActive=true') + .set('Authorization', `Bearer ${signToken(merchant._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.webhooks).toHaveLength(1); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 2, totalPages: 2 }); + }); +}); + +// ─── Event log ──────────────────────────────────────────────────────────────── + +describe('GET /api/v1/eventlog/unprocessed', () => { + it('paginates pending events and returns the shared meta', async () => { + const admin = await createUser(UserRole.ADMIN); + + for (let i = 0; i < 3; i++) { + await EventLog.create({ + eventType: 'delivery', + transactionHash: `0xqc${i}${'0'.repeat(56)}`, + ledgerSequence: 1000 + i, + contractId: 'CQC', + eventData: { amount: 1 }, + status: 'pending', + }); + } + + const res = await request(app) + .get('/api/v1/eventlog/unprocessed?limit=2') + .set('Authorization', `Bearer ${signToken(admin._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.events).toHaveLength(2); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 3, totalPages: 2 }); + }); +}); diff --git a/tests/notificationService.test.ts b/tests/notificationService.test.ts index 688aa98..f654a02 100644 --- a/tests/notificationService.test.ts +++ b/tests/notificationService.test.ts @@ -407,7 +407,7 @@ describe('NotificationService', () => { await service.notifyDeliveryTransition(delivery, DeliveryStatus.IN_PROGRESS); await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); - const page = await service.listForUser(userId, 1, 10); + const page = await service.listForUser(userId, { page: 1, limit: 10 }); expect(page.total).toBe(3); expect(page.data[0].event).toBe(NotificationEvent.DELIVERY_COMPLETED); @@ -420,7 +420,7 @@ describe('NotificationService', () => { await service.notifyDeliveryTransition(delivery, DeliveryStatus.PENDING); await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); - const page = await service.listForUser(userId, 1, 1); + const page = await service.listForUser(userId, { page: 1, limit: 1 }); expect(page.data).toHaveLength(1); expect(page.total).toBe(2); diff --git a/tests/providers.test.ts b/tests/providers.test.ts new file mode 100644 index 0000000..c5dd571 --- /dev/null +++ b/tests/providers.test.ts @@ -0,0 +1,240 @@ +/** + * Unit tests for the external SDK provider interfaces (issue #222). + * + * Everything external is behind an interface now, so these tests inject fakes + * (or mock only the raw SDK transport) and verify: + * - GoogleMapsRoutingProvider: Google path, Haversine fallback and error + * handling — without network calls; + * - SdkSorobanRpcClient: 1:1 delegation onto the underlying rpc.Server; + * - PoolingService: routing injected as a fake, no Google Maps involved; + * - DI container: the provider contracts resolve as singletons. + */ + +import axios from 'axios'; +import { GoogleMapsRoutingProvider, SdkSorobanRpcClient } from '../src/services/providers/adapters'; +import { routingService } from '../src/services/routingService'; +import { PoolingService } from '../src/services/poolingService'; +import type { + IRoutingProvider, + ETARequest, + ETAResponse, +} from '../src/services/providers/routingProvider'; +import type { ISorobanRpcClient } from '../src/services/providers/sorobanRpcClient'; + +jest.mock('axios'); +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +// The stellar config pulls in the SDK's horizon axios client, which cannot be +// constructed under jest once axios is mocked; the adapters only need the +// default client reference, which is never called in these tests. +jest.mock('../src/config/stellar', () => ({ + stellarConfig: { + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + network: 'testnet', + timeoutMs: 10000, + }, + sorobanRpcClient: {}, + createSorobanRpcClient: jest.fn(), +})); + +const mockedAxios = axios as jest.Mocked; + +afterEach(() => { + jest.clearAllMocks(); +}); + +/** Deterministic routing fake returning a fixed ETA. */ +function makeRoutingFake(eta: Partial = {}): IRoutingProvider { + return { + calculateETA: jest.fn().mockResolvedValue({ + estimatedTime: 12, + distance: 5.4, + durationText: '12 mins', + distanceText: '5.4 km', + route: { + distance: 5400, + duration: 720, + distanceText: '5.4 km', + durationText: '12 mins', + }, + ...eta, + }), + }; +} + +// ─── GoogleMapsRoutingProvider ──────────────────────────────────────────────── + +describe('GoogleMapsRoutingProvider', () => { + const baseRequest: ETARequest = { + pickup: { lat: 40.7128, lng: -74.006 }, + dropoff: { lat: 40.7589, lng: -73.9851 }, + }; + + it('is configured for the Google path only when an API key is present', async () => { + const provider = new GoogleMapsRoutingProvider('test-key'); + mockedAxios.get.mockResolvedValue({ + data: { + status: 'OK', + routes: [ + { + legs: [ + { + distance: { value: 5400, text: '5.4 km' }, + duration: { value: 720, text: '12 mins' }, + }, + ], + }, + ], + }, + }); + + const eta = await provider.calculateETA(baseRequest); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(eta.estimatedTime).toBe(12); + expect(eta.route.distance).toBe(5400); + }); + + it('falls back to the Haversine estimate when no API key is configured', async () => { + const provider = new GoogleMapsRoutingProvider(''); + + const eta = await provider.calculateETA(baseRequest); + + expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(eta.estimatedTime).toBeGreaterThan(0); + expect(eta.durationText).toMatch(/\d+ mins/); + expect(eta.distanceText).toMatch(/km/); + }); + + it('normalises longitudes across the anti-meridian in the fallback', async () => { + const provider = new GoogleMapsRoutingProvider(''); + + // Fiji → Samoa: the short way is ~1,100 km, not ~19,000 km. + const eta = await provider.calculateETA({ + pickup: { lat: -18.1248, lng: 178.4501 }, + dropoff: { lat: -13.759, lng: -172.1046 }, + }); + + expect(eta.distance).toBeGreaterThan(1000); + expect(eta.distance).toBeLessThan(1300); + }); + + it('wraps provider failures in a generic ETA error', async () => { + const provider = new GoogleMapsRoutingProvider('test-key'); + mockedAxios.get.mockRejectedValue(new Error('boom')); + + await expect(provider.calculateETA(baseRequest)).rejects.toThrow( + 'Failed to calculate delivery ETA', + ); + }); +}); + +// ─── SdkSorobanRpcClient ────────────────────────────────────────────────────── + +describe('SdkSorobanRpcClient', () => { + it('delegates every call 1:1 onto the underlying rpc.Server', async () => { + const server = { + getAccount: jest.fn().mockResolvedValue({ accountId: 'GABC' }), + getHealth: jest.fn().mockResolvedValue({ status: 'healthy' }), + getTransaction: jest.fn().mockResolvedValue({ status: 'NOT_FOUND' }), + getEvents: jest.fn().mockResolvedValue({ events: [], latestLedger: 1, cursor: '' }), + getLatestLedger: jest.fn().mockResolvedValue({ sequence: 42 }), + getNetwork: jest.fn().mockResolvedValue({ passphrase: 'test' }), + prepareTransaction: jest.fn().mockResolvedValue({ sequence: 43 }), + sendTransaction: jest.fn().mockResolvedValue({ status: 'PENDING', hash: 'abc' }), + }; + const client = new SdkSorobanRpcClient(server as never); + const tx = { fake: 'transaction' } as never; + + await client.getAccount('GABC'); + await client.getHealth(); + await client.getTransaction('hash'); + await client.getEvents({ filters: [], startLedger: 1 }); + await client.getLatestLedger(); + await client.getNetwork(); + await client.prepareTransaction(tx); + await client.sendTransaction(tx); + + expect(server.getAccount).toHaveBeenCalledWith('GABC'); + expect(server.getHealth).toHaveBeenCalledTimes(1); + expect(server.getTransaction).toHaveBeenCalledWith('hash'); + expect(server.getEvents).toHaveBeenCalledWith({ filters: [], startLedger: 1 }); + expect(server.getLatestLedger).toHaveBeenCalledTimes(1); + expect(server.getNetwork).toHaveBeenCalledTimes(1); + expect(server.prepareTransaction).toHaveBeenCalledWith(tx); + expect(server.sendTransaction).toHaveBeenCalledWith(tx); + }); + + it('satisfies the ISorobanRpcClient contract', () => { + const server = {} as never; + const client: ISorobanRpcClient = new SdkSorobanRpcClient(server); + expect(client).toBeDefined(); + }); +}); + +// ─── Consumers depending on the interfaces ──────────────────────────────────── + +describe('routing facade', () => { + it('delegates to the injected provider', async () => { + const fake = makeRoutingFake(); + const service = new (routingService.constructor as new ( + p: IRoutingProvider, + ) => typeof routingService)(fake); + + const result = await service.calculateETA({ + pickup: { lat: 1, lng: 1 }, + dropoff: { lat: 2, lng: 2 }, + }); + + expect(fake.calculateETA).toHaveBeenCalledTimes(1); + expect(result.estimatedTime).toBe(12); + }); +}); + +describe('PoolingService with an injected routing fake', () => { + it('sums pool ETA from the provider without any Google Maps involvement', async () => { + const fake = makeRoutingFake({ + estimatedTime: 5, + route: { + distance: 500, + duration: 300, + distanceText: '0.5 km', + durationText: '5 mins', + }, + }); + const pooling = new PoolingService(fake); + + const buildPool = ( + pooling as unknown as { + buildPool: (deliveries: never[]) => Promise<{ + totalEstimatedDistanceMeters: number; + totalEstimatedDurationMinutes: number; + }>; + } + ).buildPool.bind(pooling); + + const summary = await buildPool([ + { + _id: 'd1', + pickupCoordinates: { lat: 1, lng: 1, address: 'A1' }, + dropoffCoordinates: { lat: 1.5, lng: 1.5, address: 'B1' }, + }, + { + _id: 'd2', + pickupCoordinates: { lat: 2, lng: 2, address: 'A2' }, + dropoffCoordinates: { lat: 2.5, lng: 2.5, address: 'B2' }, + }, + ] as never); + + // 2 deliveries → 4 stops (pickup + dropoff each) → 3 legs between them. + expect(fake.calculateETA).toHaveBeenCalledTimes(3); + expect(summary.totalEstimatedDistanceMeters).toBe(1500); + expect(summary.totalEstimatedDurationMinutes).toBe(15); + }); +});