diff --git a/package-lock.json b/package-lock.json index 92e5167..881d949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "typescript": "^5.9.3" "typescript": "^5.3.3" }, "engines": { @@ -10225,6 +10226,9 @@ } }, "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", diff --git a/package.json b/package.json index 3645e5d..e7de640 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,6 @@ "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.3.3" + "typescript": "^5.9.3" } } diff --git a/src/app.ts b/src/app.ts index c35e5c1..f5bf8db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -109,7 +109,7 @@ export function createApp({ cors({ origin: http?.corsAllowedOrigins ?? true, credentials: http?.corsAllowCredentials ?? false, - }), + }) ); if (kycService) { @@ -122,28 +122,26 @@ export function createApp({ // FORCE RATE LIMITER (tests depend on it) if (http?.rateLimit?.enabled !== false) { - applyRateLimiters(app, appLogger, { - global: http?.rateLimit - ? { - windowMs: http.rateLimit.windowMs ?? 60_000, - max: http.rateLimit.max ?? 100, - } - : undefined, -}); -} + applyRateLimiters(app, appLogger, { + global: http?.rateLimit + ? { + windowMs: http.rateLimit.windowMs ?? 60_000, + max: http.rateLimit.max ?? 100, + } + : undefined, + }); + } app.use( createRequestObservabilityMiddleware({ logger: appLogger, metricsEnabled, metricsRegistry, - }), + }) ); app.get("/health", (req, res) => { const requestId = - (req.headers["x-request-id"] as string) || - (req as RequestWithId).requestId || - "unknown"; + (req.headers["x-request-id"] as string) || (req as RequestWithId).requestId || "unknown"; res.setHeader("x-request-id", requestId); @@ -212,7 +210,7 @@ export function createApp({ authService, contractGuardService, contractId: pauseGuardContractId, - }), + }) ); } @@ -223,7 +221,7 @@ export function createApp({ settlementService, contractGuardService, contractId: pauseGuardContractId, - }), + }) ); } @@ -234,7 +232,7 @@ export function createApp({ if (config?.admin?.ipWhitelist?.length) { app.use( "/api/v1/admin", - createAdminRouter({ dataSource, allowedCidrs: config.admin.ipWhitelist, invoiceService }), + createAdminRouter({ dataSource, allowedCidrs: config.admin.ipWhitelist, invoiceService }) ); } diff --git a/src/config/data-source.ts b/src/config/data-source.ts index 0541cae..4a210e0 100644 --- a/src/config/data-source.ts +++ b/src/config/data-source.ts @@ -29,6 +29,8 @@ export async function initializeDataSource(): Promise { } } catch (error) { logger.error("Failed to initialize DataSource", { error }); - throw new Error(`DataSource initialization failed: ${error instanceof Error ? error.message : String(error)}`); + throw new Error( + `DataSource initialization failed: ${error instanceof Error ? error.message : String(error)}` + ); } } diff --git a/src/config/database.ts b/src/config/database.ts index 4711a9c..1ae49b2 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -20,7 +20,9 @@ let lastPoolErrorLog = 0; * Logs a warn when utilisation exceeds 80% and error at 100%. * Each log is emitted at most once per 30 seconds. */ -export function startPoolMonitor(getPool: () => { totalCount: number; idleCount: number; waitingCount: number } | null): void { +export function startPoolMonitor( + getPool: () => { totalCount: number; idleCount: number; waitingCount: number } | null +): void { setInterval(() => { const pool = getPool(); if (!pool) return; @@ -85,9 +87,17 @@ if (!isDevelopment) { startPoolMonitor(() => { try { // TypeORM exposes the underlying pg pool via driver.master/slave - const pool = (dataSource.driver as unknown as { master?: { totalCount: number; idleCount: number; waitingCount: number } })?.master; + const pool = ( + dataSource.driver as unknown as { + master?: { totalCount: number; idleCount: number; waitingCount: number }; + } + )?.master; if (pool && typeof pool.totalCount === "number") { - return { totalCount: pool.totalCount, idleCount: pool.idleCount, waitingCount: pool.waitingCount }; + return { + totalCount: pool.totalCount, + idleCount: pool.idleCount, + waitingCount: pool.waitingCount, + }; } } catch { // Ignore — pool not yet initialised diff --git a/src/config/env.ts b/src/config/env.ts index c15f04f..2805fd7 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -68,7 +68,6 @@ export interface AppConfig { }; } - // ---------------- DEFAULTS ---------------- const DEFAULT_PORT = 3000; @@ -97,7 +96,6 @@ const DEFAULT_IPFS_ALLOWED_MIME_TYPES = [ const DEFAULT_IPFS_UPLOAD_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; const DEFAULT_IPFS_UPLOAD_RATE_LIMIT_MAX_UPLOADS = 10; - // ---------------- HELPERS ---------------- function parsePort(value?: string): number { @@ -130,7 +128,10 @@ function parseBoolean(value: string | undefined, fallback: boolean, name: string function parseCsv(value?: string): string[] { if (!value) return []; - return value.split(",").map(v => v.trim()).filter(Boolean); + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); } function parseTrustProxy(value?: string): boolean | number | string { @@ -166,7 +167,6 @@ function requireString(value: string | undefined, name: string): string { return value; } - // ---------------- MAIN CONFIG ---------------- export function getConfig(): AppConfig { @@ -216,11 +216,7 @@ export function getConfig(): AppConfig { 60000, "RATE_LIMIT_WINDOW_MS" ), - max: parsePositiveInteger( - process.env.RATE_LIMIT_MAX, - 100, - "RATE_LIMIT_MAX" - ), + max: parsePositiveInteger(process.env.RATE_LIMIT_MAX, 100, "RATE_LIMIT_MAX"), }, }, diff --git a/src/config/stellar.ts b/src/config/stellar.ts index 960cc84..ec57632 100644 --- a/src/config/stellar.ts +++ b/src/config/stellar.ts @@ -52,25 +52,21 @@ export function getSorobanConfig(): SorobanConfig { "https://soroban-testnet.stellar.org"; const networkPassphrase = - process.env.STELLAR_NETWORK_PASSPHRASE ?? - "Test SDF Network ; September 2015"; + process.env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const escrowContractId = process.env.SOROBAN_ESCROW_CONTRACT_ID ?? process.env.ESCROW_CONTRACT_ID ?? "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; - const tokenContractId = - process.env.SOROBAN_TOKEN_CONTRACT_ID ?? - process.env.TOKEN_CONTRACT_ID; + const tokenContractId = process.env.SOROBAN_TOKEN_CONTRACT_ID ?? process.env.TOKEN_CONTRACT_ID; const paymentDistributorContractId = process.env.SOROBAN_PAYMENT_DISTRIBUTOR_CONTRACT_ID ?? process.env.PAYMENT_DISTRIBUTOR_CONTRACT_ID; const platformSecretKey = - process.env.STELLAR_PLATFORM_SECRET_KEY ?? - process.env.PLATFORM_SECRET_KEY; + process.env.STELLAR_PLATFORM_SECRET_KEY ?? process.env.PLATFORM_SECRET_KEY; const platformFeeRecipient = process.env.PLATFORM_FEE_RECIPIENT; const platformFeeBps = Number(process.env.PLATFORM_FEE_BPS ?? "0"); if (!Number.isInteger(platformFeeBps) || platformFeeBps < 0 || platformFeeBps > 10_000) { @@ -92,29 +88,20 @@ export function getSorobanConfig(): SorobanConfig { export function getPaymentVerificationConfig(): PaymentVerificationConfig { return { horizonUrl: requireEnv(process.env.STELLAR_HORIZON_URL, "STELLAR_HORIZON_URL"), - usdcAssetCode: requireEnv( - process.env.STELLAR_USDC_ASSET_CODE, - "STELLAR_USDC_ASSET_CODE", - ), - usdcAssetIssuer: requireEnv( - process.env.STELLAR_USDC_ASSET_ISSUER, - "STELLAR_USDC_ASSET_ISSUER", - ), - escrowPublicKey: requireEnv( - process.env.STELLAR_ESCROW_PUBLIC_KEY, - "STELLAR_ESCROW_PUBLIC_KEY", - ), + usdcAssetCode: requireEnv(process.env.STELLAR_USDC_ASSET_CODE, "STELLAR_USDC_ASSET_CODE"), + usdcAssetIssuer: requireEnv(process.env.STELLAR_USDC_ASSET_ISSUER, "STELLAR_USDC_ASSET_ISSUER"), + escrowPublicKey: requireEnv(process.env.STELLAR_ESCROW_PUBLIC_KEY, "STELLAR_ESCROW_PUBLIC_KEY"), allowedAmountDelta: process.env.STELLAR_VERIFY_ALLOWED_AMOUNT_DELTA ?? DEFAULT_ALLOWED_AMOUNT_DELTA, retryAttempts: parsePositiveInteger( process.env.STELLAR_VERIFY_RETRY_ATTEMPTS, DEFAULT_RETRY_ATTEMPTS, - "STELLAR_VERIFY_RETRY_ATTEMPTS", + "STELLAR_VERIFY_RETRY_ATTEMPTS" ), retryBaseDelayMs: parsePositiveInteger( process.env.STELLAR_VERIFY_RETRY_BASE_DELAY_MS, DEFAULT_RETRY_BASE_DELAY_MS, - "STELLAR_VERIFY_RETRY_BASE_DELAY_MS", + "STELLAR_VERIFY_RETRY_BASE_DELAY_MS" ), }; } diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index d2623a1..c69174a 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -6,10 +6,7 @@ import type { AuthenticatedRequest } from "../types/auth"; export function createAuthController(authService: AuthService) { return { // Request challenge for signing - challenge: async ( - req: AuthenticatedRequest, - res: Response - ): Promise => { + challenge: async (req: AuthenticatedRequest, res: Response): Promise => { const challenge = await authService.createChallenge(req.body.publicKey); res.status(201).json({ challenge }); }, @@ -20,7 +17,8 @@ export function createAuthController(authService: AuthService) { res: Response ): Promise => { const forwarded = req.headers["x-forwarded-for"]; - const ipAddress = (Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0]?.trim()) ?? req.ip; + const ipAddress = + (Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0]?.trim()) ?? req.ip; const session = await authService.verifyChallenge({ ...req.body, ipAddress }); res.status(200).json(session); }, @@ -36,4 +34,4 @@ export function createAuthController(authService: AuthService) { res.status(200).json({ user }); }, }; -} \ No newline at end of file +} diff --git a/src/controllers/investment.controller.ts b/src/controllers/investment.controller.ts index f9497b8..e4304e3 100644 --- a/src/controllers/investment.controller.ts +++ b/src/controllers/investment.controller.ts @@ -39,7 +39,8 @@ export class InvestmentController { data: investment, }); } catch (err: unknown) { - const statusCode = (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 400; + const statusCode = + (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 400; return res.status(statusCode).json({ error: { code: (err as { code?: string }).code || "INTERNAL_ERROR", @@ -63,7 +64,8 @@ export class InvestmentController { data: dashboard, }); } catch (err: unknown) { - const statusCode = (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 500; + const statusCode = + (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 500; return res.status(statusCode).json({ error: { code: (err as { code?: string }).code || "INTERNAL_ERROR", @@ -87,7 +89,8 @@ export class InvestmentController { data: analytics, }); } catch (err: unknown) { - const statusCode = (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 500; + const statusCode = + (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 500; return res.status(statusCode).json({ error: { code: (err as { code?: string }).code || "INTERNAL_ERROR", diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index d7286cb..9ccf7e1 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -63,22 +63,15 @@ export function createInvoiceController(invoiceService: InvoiceService) { async createInvoice( req: CreateInvoiceRequest, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { if (!req.user) { throw new HttpError(401, "Authentication required"); } - const { - invoiceNumber, - customerName, - amount, - discountRate, - dueDate, - ipfsHash, - riskScore, - } = req.body; + const { invoiceNumber, customerName, amount, discountRate, dueDate, ipfsHash, riskScore } = + req.body; const result = await invoiceService.createInvoice({ sellerId: req.user.id, @@ -105,11 +98,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { } }, - async getInvoices( - req: GetInvoicesRequest, - res: Response, - next: NextFunction, - ): Promise { + async getInvoices(req: GetInvoicesRequest, res: Response, next: NextFunction): Promise { try { if (!req.user) { throw new HttpError(401, "Authentication required"); @@ -154,7 +143,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async getInvoice( req: Request & { params: { id: string } }, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { const authReq = req as AuthenticatedRequest; @@ -165,10 +154,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { const { id } = req.params; try { - const result = await invoiceService.getInvoiceById( - id, - authReq.user.id, - ); + const result = await invoiceService.getInvoiceById(id, authReq.user.id); if (!result) { throw new HttpError(404, "Invoice not found"); @@ -198,7 +184,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async updateInvoice( req: UpdateInvoiceRequest, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { if (!req.user) { @@ -206,8 +192,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { } const { id } = req.params; - const { customerName, amount, discountRate, dueDate, riskScore } = - req.body; + const { customerName, amount, discountRate, dueDate, riskScore } = req.body; const result = await invoiceService.updateInvoice({ sellerId: req.user.id, @@ -241,7 +226,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async deleteInvoice( req: Request & { params: { id: string } }, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { const authReq = req as AuthenticatedRequest; @@ -272,7 +257,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async publishInvoice( req: PublishInvoiceRequest, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { if (!req.user) { @@ -308,7 +293,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async batchPublishInvoices( req: BatchPublishInvoicesRequest, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { if (!req.user) { @@ -340,7 +325,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async uploadDocument( req: UploadDocumentRequest, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { if (!req.user) { @@ -379,7 +364,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async getInvoiceTokenHolders( req: Request & { params: { id: string } }, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { const { id } = req.params; @@ -403,7 +388,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { async getInvoiceEscrowStatus( req: Request & { params: { id: string } }, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { const { id } = req.params; @@ -424,11 +409,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { } }, - async calculateTerms( - req: Request, - res: Response, - next: NextFunction, - ): Promise { + async calculateTerms(req: Request, res: Response, next: NextFunction): Promise { try { const { faceValue, dueDate, discountBps, platformFeeBps, referenceDate } = req.body; @@ -446,9 +427,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { }); } catch (error) { const message = - error instanceof Error - ? error.message - : "Failed to calculate invoice terms"; + error instanceof Error ? error.message : "Failed to calculate invoice terms"; next(new HttpError(400, message)); } }, diff --git a/src/controllers/kyc.controller.ts b/src/controllers/kyc.controller.ts index e2d0b20..28248dc 100644 --- a/src/controllers/kyc.controller.ts +++ b/src/controllers/kyc.controller.ts @@ -11,17 +11,23 @@ export function createKycController(service: KycService) { }, webhook: async (req: Request, res: Response) => { if (!Buffer.isBuffer(req.body)) { - return res.status(400).json({ error: { code: "RAW_BODY_REQUIRED", message: "Raw webhook body required" } }); + return res + .status(400) + .json({ error: { code: "RAW_BODY_REQUIRED", message: "Raw webhook body required" } }); } const rawBody = req.body; if (!service.verifyWebhookSignature(rawBody, req.header("x-provider-signature"))) { - return res.status(401).json({ error: { code: "INVALID_WEBHOOK_SIGNATURE", message: "Invalid webhook signature" } }); + return res.status(401).json({ + error: { code: "INVALID_WEBHOOK_SIGNATURE", message: "Invalid webhook signature" }, + }); } let payload: unknown; try { payload = JSON.parse(rawBody.toString("utf8")); } catch { - return res.status(400).json({ error: { code: "INVALID_WEBHOOK_PAYLOAD", message: "Invalid JSON payload" } }); + return res + .status(400) + .json({ error: { code: "INVALID_WEBHOOK_PAYLOAD", message: "Invalid JSON payload" } }); } await service.processWebhook(payload as Parameters[0]); return res.status(204).send(); diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts index bdcb512..75f59b6 100644 --- a/src/controllers/marketplace.controller.ts +++ b/src/controllers/marketplace.controller.ts @@ -1,6 +1,10 @@ import type { Request, Response, NextFunction } from "express"; import Joi from "joi"; -import type { MarketplaceService, MarketplaceFilters, PaginationOptions } from "../services/marketplace.service"; +import type { + MarketplaceService, + MarketplaceFilters, + PaginationOptions, +} from "../services/marketplace.service"; import { InvoiceStatus } from "../types/enums"; import { HttpError } from "../utils/http-error"; import { ServiceError } from "../utils/service-error"; @@ -8,10 +12,12 @@ import { ServiceError } from "../utils/service-error"; const getInvoicesSchema = Joi.object({ page: Joi.number().integer().min(1).default(1), limit: Joi.number().integer().min(1).max(100).default(20), - status: Joi.alternatives().try( - Joi.string().valid(...Object.values(InvoiceStatus)), - Joi.array().items(Joi.string().valid(...Object.values(InvoiceStatus))), - ).optional(), + status: Joi.alternatives() + .try( + Joi.string().valid(...Object.values(InvoiceStatus)), + Joi.array().items(Joi.string().valid(...Object.values(InvoiceStatus))) + ) + .optional(), dueBefore: Joi.date().iso().optional(), minAmount: Joi.number().min(0).optional(), maxAmount: Joi.number().min(0).optional(), @@ -35,11 +41,7 @@ export interface GetInvoicesRequest extends Request { export function createMarketplaceController(marketplaceService: MarketplaceService) { return { - async getInvoices( - req: GetInvoicesRequest, - res: Response, - next: NextFunction, - ): Promise { + async getInvoices(req: GetInvoicesRequest, res: Response, next: NextFunction): Promise { try { // Validate query parameters const { error, value } = getInvoicesSchema.validate(req.query, { @@ -53,7 +55,11 @@ export function createMarketplaceController(marketplaceService: MarketplaceServi // Parse and normalize filters const filters: MarketplaceFilters = { - status: Array.isArray(value.status) ? value.status : value.status ? [value.status] : undefined, + status: Array.isArray(value.status) + ? value.status + : value.status + ? [value.status] + : undefined, dueBefore: value.dueBefore, minAmount: value.minAmount, maxAmount: value.maxAmount, @@ -91,4 +97,4 @@ export function createMarketplaceController(marketplaceService: MarketplaceServi } }, }; -} \ No newline at end of file +} diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts index 14da7e8..2c0ecc5 100644 --- a/src/controllers/notification.controller.ts +++ b/src/controllers/notification.controller.ts @@ -2,9 +2,7 @@ import type { Request, Response } from "express"; import type { NotificationService } from "../services/notification.service"; import { NotificationType } from "../types/enums"; -export function createNotificationController( - notificationService: NotificationService, -) { +export function createNotificationController(notificationService: NotificationService) { return { list: async (req: Request, res: Response): Promise => { const userId = req.user!.id; @@ -12,7 +10,7 @@ export function createNotificationController( const page = Math.max(1, parseInt((req.query.page as string) ?? "1", 10) || 1); const limit = Math.min( 100, - Math.max(1, parseInt((req.query.limit as string) ?? "20", 10) || 20), + Math.max(1, parseInt((req.query.limit as string) ?? "20", 10) || 20) ); const readParam = req.query.read as string | undefined; @@ -26,8 +24,7 @@ export function createNotificationController( ? (typeParam as NotificationType) : undefined; - const sortOrder = - (req.query.sort as string) === "asc" ? ("asc" as const) : ("desc" as const); + const sortOrder = (req.query.sort as string) === "asc" ? ("asc" as const) : ("desc" as const); const cursor = req.query.cursor as string | undefined; const result = await notificationService.listNotifications({ diff --git a/src/controllers/settlement.controller.ts b/src/controllers/settlement.controller.ts index 8477564..f0f7f31 100644 --- a/src/controllers/settlement.controller.ts +++ b/src/controllers/settlement.controller.ts @@ -35,9 +35,7 @@ export class SettlementController { }); } catch (err: unknown) { const statusCode = - (err as { statusCode?: number }).statusCode || - (err as { status?: number }).status || - 400; + (err as { statusCode?: number }).statusCode || (err as { status?: number }).status || 400; return res.status(statusCode).json({ error: { code: (err as { code?: string }).code || "INTERNAL_ERROR", diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 708d38f..19eb5c7 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -8,9 +8,17 @@ import { UserType } from "../types/enums"; export interface UserRepositoryContract { findById(id: string): Promise; findByStellarAddress(address: string): Promise; - findAll(options?: { skip?: number; take?: number }): Promise; - count?(): Promise; - save(user: Partial): Promise; + findByEmail(email: string): Promise; + findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }): Promise; + count(options?: { cursor?: string }): Promise; + save( + user: Partial + ): Promise; } export interface UserControllerDeps { @@ -18,6 +26,9 @@ export interface UserControllerDeps { logger?: AppLogger; } +const MAX_CURSOR_LIMIT = 100; +const DEFAULT_CURSOR_LIMIT = 20; + function sanitizeString(value: unknown, maxLength = 255): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -25,6 +36,14 @@ function sanitizeString(value: unknown, maxLength = 255): string | null { return trimmed.slice(0, maxLength); } +function sanitizeCursor(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (!/^[0-9a-fA-F-]{36}$/.test(trimmed)) return null; + return trimmed; +} + function toPublicUser(user: import("../models/User.model").User) { return { id: user.id, @@ -38,16 +57,28 @@ function toPublicUser(user: import("../models/User.model").User) { }; } +function getRequestId(req: AuthenticatedRequest): string { + return ( + (req.headers["x-request-id"] as string) || + ((req as unknown as Record).requestId as string) || + "unknown" + ); +} + +function setCacheHeaders(res: Response, user: import("../models/User.model").User): void { + const etag = `W/"${user.id}-${user.updatedAt.getTime()}"`; + res.setHeader("ETag", etag); + res.setHeader("Last-Modified", user.updatedAt.toUTCString()); + res.setHeader("Cache-Control", "private, max-age=60"); +} + export function createUserController(deps: UserControllerDeps) { const userRepository = deps.userRepository; const appLogger = deps.logger ?? defaultLogger; return { - async getProfile( - req: AuthenticatedRequest, - res: Response, - next: NextFunction, - ): Promise { + async getProfile(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + const requestId = getRequestId(req); try { if (!req.user?.id) { throw new HttpError(401, "Authentication required"); @@ -56,7 +87,8 @@ export function createUserController(deps: UserControllerDeps) { if (!user) { throw new HttpError(404, "User not found"); } - res.status(200).json({ success: true, data: toPublicUser(user) }); + setCacheHeaders(res, user); + res.status(200).json({ success: true, data: toPublicUser(user), requestId }); } catch (error) { if (error instanceof HttpError || error instanceof AppError) { next(error); @@ -65,6 +97,7 @@ export function createUserController(deps: UserControllerDeps) { appLogger.error("Failed to fetch user profile", { error: error instanceof Error ? error.message : String(error), userId: req.user?.id, + requestId, }); next(new AppError(500, "Failed to fetch profile", "PROFILE_FETCH_FAILED")); } @@ -73,8 +106,9 @@ export function createUserController(deps: UserControllerDeps) { async getUserById( req: AuthenticatedRequest & { params: { id: string } }, res: Response, - next: NextFunction, + next: NextFunction ): Promise { + const requestId = getRequestId(req); try { if (!req.user) { throw new HttpError(401, "Authentication required"); @@ -88,7 +122,8 @@ export function createUserController(deps: UserControllerDeps) { if (!user) { throw new HttpError(404, "User not found"); } - res.status(200).json({ success: true, data: toPublicUser(user) }); + setCacheHeaders(res, user); + res.status(200).json({ success: true, data: toPublicUser(user), requestId }); } catch (error) { if (error instanceof HttpError || error instanceof AppError) { next(error); @@ -97,6 +132,7 @@ export function createUserController(deps: UserControllerDeps) { appLogger.error("Failed to fetch user by id", { error: error instanceof Error ? error.message : String(error), params: req.params, + requestId, }); next(new AppError(500, "Failed to fetch user", "USER_FETCH_FAILED")); } @@ -105,8 +141,9 @@ export function createUserController(deps: UserControllerDeps) { async updateProfile( req: AuthenticatedRequest, res: Response, - next: NextFunction, + next: NextFunction ): Promise { + const requestId = getRequestId(req); try { if (!req.user?.id) { throw new HttpError(401, "Authentication required"); @@ -121,6 +158,10 @@ export function createUserController(deps: UserControllerDeps) { if (!emailRegex.test(email)) { throw new HttpError(400, "Invalid email format"); } + const existingEmail = await userRepository.findByEmail(email); + if (existingEmail && existingEmail.id !== req.user.id) { + throw new HttpError(409, "Email already in use"); + } } if (stellarAddressRaw !== null && !isValidStellarPublicKey(stellarAddressRaw)) { @@ -149,15 +190,16 @@ export function createUserController(deps: UserControllerDeps) { if (stellarAddressRaw !== null) patch.stellarAddress = stellarAddressRaw; if (userTypeRaw) patch.userType = userTypeRaw; - // Use immutable update pattern: create new object const updated = await userRepository.save({ ...existing, ...patch }); appLogger.info("User profile updated", { userId: req.user.id, updatedFields: Object.keys(patch), + requestId, }); - res.status(200).json({ success: true, data: toPublicUser(updated) }); + setCacheHeaders(res, updated); + res.status(200).json({ success: true, data: toPublicUser(updated), requestId }); } catch (error) { if (error instanceof HttpError || error instanceof AppError) { next(error); @@ -166,29 +208,73 @@ export function createUserController(deps: UserControllerDeps) { appLogger.error("Failed to update user profile", { error: error instanceof Error ? error.message : String(error), userId: req.user?.id, + requestId, }); next(new AppError(500, "Failed to update profile", "PROFILE_UPDATE_FAILED")); } }, async listUsers( - req: AuthenticatedRequest & { query: { page?: string; limit?: string } }, + req: AuthenticatedRequest & { + query: { page?: string; limit?: string; cursor?: string; order?: string }; + }, res: Response, - next: NextFunction, + next: NextFunction ): Promise { + const requestId = getRequestId(req); try { if (!req.user) { throw new HttpError(401, "Authentication required"); } - // Backward-compatible pagination with sanitized bounds - const page = Math.min(Math.max(Number(req.query.page) || 1, 1), 1000); - const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100); + const useCursor = typeof req.query.cursor === "string" && req.query.cursor.length > 0; + let cursor: string | null = null; + let limit = DEFAULT_CURSOR_LIMIT; + let order: "ASC" | "DESC" = "DESC"; + + if (useCursor) { + cursor = sanitizeCursor(req.query.cursor); + if (!cursor) { + throw new HttpError(400, "Invalid cursor format"); + } + limit = Math.min( + Math.max(Number(req.query.limit) || DEFAULT_CURSOR_LIMIT, 1), + MAX_CURSOR_LIMIT + ); + if (req.query.order === "asc" || req.query.order === "ASC") { + order = "ASC"; + } + } else { + const _page = Math.min(Math.max(Number(req.query.page) || 1, 1), 1000); + limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100); + } - let users: import("../models/User.model").User[]; + let users: import("../models/User.model").User[] = []; let total = 0; + let nextCursor: string | undefined = undefined; try { + if (useCursor) { + users = await userRepository.findAll({ + cursor: cursor ?? undefined, + take: limit + 1, + order, + }); + total = await userRepository.count({ cursor: cursor ?? undefined }); + if (users.length > limit) { + const nextUser = users.pop(); + nextCursor = nextUser!.id; + } + } else { + const page = Math.min(Math.max(Number(req.query.page) || 1, 1), 1000); + const skip = (page - 1) * limit; + const [fetchedUsers, fetchedTotal] = await Promise.all([ + userRepository.findAll({ skip, take: limit }), + userRepository.count ? userRepository.count() : Promise.resolve(0), + ]); + users = fetchedUsers; + total = fetchedTotal; + } // Parallelized data and count fetching for high concurrency performance const [fetchedUsers, fetchedCount] = await Promise.all([ userRepository.findAll({ skip: (page - 1) * limit, take: limit }), @@ -198,15 +284,29 @@ export function createUserController(deps: UserControllerDeps) { users = fetchedUsers; total = fetchedCount >= 0 ? fetchedCount : users.length; } catch (error) { - appLogger.error("Failed to list users", { error }); + appLogger.error("Failed to list users", { error, requestId }); throw new AppError(500, "Failed to list users", "USER_LIST_FAILED"); } - res.status(200).json({ + const response: { + success: boolean; + data: ReturnType[]; + requestId: string; + meta?: Record; + } = { success: true, data: users.map(toPublicUser), - meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, - }); + requestId, + }; + + if (useCursor) { + response.meta = { total, limit, nextCursor }; + } else { + const page = Math.min(Math.max(Number(req.query.page) || 1, 1), 1000); + response.meta = { total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + res.status(200).json(response); } catch (error) { if (error instanceof HttpError || error instanceof AppError) { next(error); @@ -214,6 +314,7 @@ export function createUserController(deps: UserControllerDeps) { } appLogger.error("Unhandled error in listUsers", { error: error instanceof Error ? error.message : String(error), + requestId, }); next(new AppError(500, "Processing failed", "USER_LIST_FAILED")); } diff --git a/src/entities/User.ts b/src/entities/User.ts index 765f68c..31d53fe 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -11,4 +11,4 @@ export class User { @Column({ type: "enum", enum: KYCStatus, default: KYCStatus.PENDING }) kycStatus!: KYCStatus; -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index ee1be32..f803f19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { createApp } from "./app"; import dataSource from "./config/database"; import { getConfig } from "./config/env"; import { logger } from "./observability/logger"; +import { MetricsRegistry } from "./observability/metrics"; import { createAuthService } from "./services/auth.service"; import { createNotificationService } from "./services/notification.service"; @@ -24,18 +25,25 @@ export async function bootstrap(): Promise<{ server: Server }> { await dataSource.initialize(); } - const authService = createAuthService(dataSource, config, logger); + const metricsRegistry = new MetricsRegistry(); + + const authService = createAuthService(dataSource, config, logger, metricsRegistry); const notificationService = createNotificationService(dataSource); const ipfsService = createIPFSService(config.ipfs, logger); const invoiceService = createInvoiceService(dataSource, ipfsService, notificationService); const investmentService = createInvestmentService(dataSource); const sorobanConfig = getSorobanConfig(); - const distributor = sorobanConfig.paymentDistributorContractId && sorobanConfig.platformSecretKey - ? new PaymentDistributorContractService({ ...sorobanConfig, contractId: sorobanConfig.paymentDistributorContractId }, logger) - : undefined; - const distributorConfig = distributor && sorobanConfig.platformFeeRecipient - ? { feeRecipient: sorobanConfig.platformFeeRecipient, feeBps: sorobanConfig.platformFeeBps } - : undefined; + const distributor = + sorobanConfig.paymentDistributorContractId && sorobanConfig.platformSecretKey + ? new PaymentDistributorContractService( + { ...sorobanConfig, contractId: sorobanConfig.paymentDistributorContractId }, + logger + ) + : undefined; + const distributorConfig = + distributor && sorobanConfig.platformFeeRecipient + ? { feeRecipient: sorobanConfig.platformFeeRecipient, feeBps: sorobanConfig.platformFeeBps } + : undefined; const settlementService = createSettlementService(dataSource, distributor, distributorConfig); const marketplaceService = createMarketplaceService(dataSource); const kycService = new KycService(dataSource, config.kyc.webhookSecret ?? "", logger); diff --git a/src/lib/auth-failure.ts b/src/lib/auth-failure.ts index d2b6d28..2959f9e 100644 --- a/src/lib/auth-failure.ts +++ b/src/lib/auth-failure.ts @@ -13,9 +13,7 @@ export interface AuthFailureDetails { failedAt: string; } -export function truncateWalletAddress( - address: string | null | undefined, -): string | null { +export function truncateWalletAddress(address: string | null | undefined): string | null { if (!address) { return null; } @@ -43,14 +41,12 @@ export function extractWalletFromUnverifiedToken(token?: string): string | null export function buildAuthFailureDetails( token: string | undefined, - reason: AuthFailureReason, + reason: AuthFailureReason ): { authFailure: AuthFailureDetails } { return { authFailure: { reason, - truncatedAddress: truncateWalletAddress( - extractWalletFromUnverifiedToken(token), - ), + truncatedAddress: truncateWalletAddress(extractWalletFromUnverifiedToken(token)), failedAt: new Date().toISOString(), }, }; diff --git a/src/lib/circuit-breaker.ts b/src/lib/circuit-breaker.ts new file mode 100644 index 0000000..3ea2cc0 --- /dev/null +++ b/src/lib/circuit-breaker.ts @@ -0,0 +1,83 @@ +export interface CircuitBreakerOptions { + failureThreshold: number; + successThreshold: number; + timeout: number; +} + +export interface CircuitBreakerState { + failures: number; + successes: number; + lastFailure: number; + state: "closed" | "open" | "half-open"; +} + +export class CircuitBreaker { + private state: CircuitBreakerState = { + failures: 0, + successes: 0, + lastFailure: 0, + state: "closed", + }; + + constructor(private readonly options: CircuitBreakerOptions) {} + + async execute(operation: () => Promise): Promise { + if (this.state.state === "open") { + if (Date.now() - this.state.lastFailure >= this.options.timeout) { + this.state.state = "half-open"; + } else { + throw new Error("Circuit breaker is open"); + } + } + + try { + const result = await operation(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess(): void { + this.state.failures = 0; + if (this.state.state === "half-open") { + this.state.successes++; + if (this.state.successes >= this.options.successThreshold) { + this.state.state = "closed"; + this.state.successes = 0; + } + } + } + + private onFailure(): void { + this.state.failures++; + this.state.lastFailure = Date.now(); + this.state.successes = 0; + if (this.state.failures >= this.options.failureThreshold) { + this.state.state = "open"; + } + } + + getState(): CircuitBreakerState { + return { ...this.state }; + } + + reset(): void { + this.state = { + failures: 0, + successes: 0, + lastFailure: 0, + state: "closed", + }; + } +} + +export function createCircuitBreaker(options?: Partial): CircuitBreaker { + return new CircuitBreaker({ + failureThreshold: options?.failureThreshold ?? 5, + successThreshold: options?.successThreshold ?? 2, + timeout: options?.timeout ?? 30000, + }); +} diff --git a/src/lib/extract-wallet-from-token.ts b/src/lib/extract-wallet-from-token.ts index 36ec362..6093630 100644 --- a/src/lib/extract-wallet-from-token.ts +++ b/src/lib/extract-wallet-from-token.ts @@ -7,10 +7,7 @@ import jwt from "jsonwebtoken"; * Returns null for: missing token, expired token, invalid signature, * missing `sub` claim. Never throws. */ -export function extractWalletFromToken( - token: string | undefined, - secret: string, -): string | null { +export function extractWalletFromToken(token: string | undefined, secret: string): string | null { if (!token) { return null; } diff --git a/src/lib/investor-return.ts b/src/lib/investor-return.ts index a3527ce..3bf8483 100644 --- a/src/lib/investor-return.ts +++ b/src/lib/investor-return.ts @@ -7,7 +7,7 @@ export function computeInvestorReturn( investedAmount: bigint, totalFunded: bigint, - settledProceeds: bigint, + settledProceeds: bigint ): bigint { if (totalFunded <= 0n) { throw new RangeError("totalFunded must be greater than zero"); diff --git a/src/lib/invoice-validation.ts b/src/lib/invoice-validation.ts index 2c0bb2a..fac11a7 100644 --- a/src/lib/invoice-validation.ts +++ b/src/lib/invoice-validation.ts @@ -13,7 +13,10 @@ export interface PublishableInvoice { * publishing, so investors always have a full day of runway before the * invoice is due. */ -export function validateInvoiceForPublish(invoice: PublishableInvoice, now: Date = new Date()): void { +export function validateInvoiceForPublish( + invoice: PublishableInvoice, + now: Date = new Date() +): void { const leadTimeMs = invoice.dueDate.getTime() - now.getTime(); if (leadTimeMs < MIN_LEAD_TIME_MS) { @@ -21,7 +24,7 @@ export function validateInvoiceForPublish(invoice: PublishableInvoice, now: Date "invalid_due_date", "dueDate must be at least 24 hours in the future", 400, - { field: "dueDate" }, + { field: "dueDate" } ); } } diff --git a/src/lib/settlement-completion-log.ts b/src/lib/settlement-completion-log.ts index 176e9a8..203371b 100644 --- a/src/lib/settlement-completion-log.ts +++ b/src/lib/settlement-completion-log.ts @@ -16,7 +16,7 @@ export interface SettlementCompletionLogInput { */ export function logSettlementCompletion( logger: AppLogger, - input: SettlementCompletionLogInput, + input: SettlementCompletionLogInput ): void { logger.info("Settlement flow completed.", { invoice_id: input.invoiceId, diff --git a/src/lib/stellar-format.ts b/src/lib/stellar-format.ts index 6cbe7fc..4b25070 100644 --- a/src/lib/stellar-format.ts +++ b/src/lib/stellar-format.ts @@ -19,9 +19,10 @@ export function stroopsToXlm(stroops: bigint, decimals: number = STROOP_DECIMALS // Pad the stroop remainder out to full precision, then round/truncate to // the requested number of decimal places. const fullFraction = remainderStroops.toString().padStart(STROOP_DECIMALS, "0"); - const fraction = decimals <= STROOP_DECIMALS - ? fullFraction.slice(0, decimals) - : fullFraction.padEnd(decimals, "0"); + const fraction = + decimals <= STROOP_DECIMALS + ? fullFraction.slice(0, decimals) + : fullFraction.padEnd(decimals, "0"); const sign = negative ? "-" : ""; return decimals > 0 ? `${sign}${whole}.${fraction}` : `${sign}${whole}`; diff --git a/src/lib/validate-invoice-for-publish.ts b/src/lib/validate-invoice-for-publish.ts index ca26390..2528eba 100644 --- a/src/lib/validate-invoice-for-publish.ts +++ b/src/lib/validate-invoice-for-publish.ts @@ -35,7 +35,7 @@ export const MIN_FACE_VALUE_XLM = new Decimal("100"); */ export function validateFundingDeadline( dueDate: Date | string, - now: Date = new Date(), + now: Date = new Date() ): ValidationError | null { const deadline = new Date(dueDate); @@ -75,7 +75,10 @@ export function validateFundingDeadline( * `now` is threaded through to {@link validateFundingDeadline} for tests only; * production callers use the default server clock. */ -export function validateInvoiceForPublish(invoice: Invoice, now: Date = new Date()): ValidationError[] { +export function validateInvoiceForPublish( + invoice: Invoice, + now: Date = new Date() +): ValidationError[] { const errors: ValidationError[] = []; const faceValue = new Decimal(invoice.amount); diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 102e130..ab00838 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -6,10 +6,7 @@ import type { AuthenticatedRequest } from "../types/auth"; import { AppError, HttpError } from "../utils/http-error"; import { UserType, KYCStatus } from "../types/enums"; -import { - buildAuthFailureDetails, - classifyJwtError, -} from "../lib/auth-failure"; +import { buildAuthFailureDetails, classifyJwtError } from "../lib/auth-failure"; interface AuthTokenPayload { sub: string; @@ -18,11 +15,7 @@ interface AuthTokenPayload { } export function createAuthMiddleware(authService: AuthService) { - return async ( - req: AuthenticatedRequest, - _res: Response, - next: NextFunction - ): Promise => { + return async (req: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { @@ -30,8 +23,8 @@ export function createAuthMiddleware(authService: AuthService) { new HttpError( 401, "Authorization token is required.", - buildAuthFailureDetails(undefined, "missing_token"), - ), + buildAuthFailureDetails(undefined, "missing_token") + ) ); return; } @@ -51,18 +44,14 @@ export function createAuthMiddleware(authService: AuthService) { new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(token, classifyJwtError(error)), - ), + buildAuthFailureDetails(token, classifyJwtError(error)) + ) ); } }; } -export function authenticateJWT( - req: Request, - _res: Response, - next: NextFunction -): void { +export function authenticateJWT(req: Request, _res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { @@ -70,8 +59,8 @@ export function authenticateJWT( new HttpError( 401, "Authorization token is required.", - buildAuthFailureDetails(undefined, "missing_token"), - ), + buildAuthFailureDetails(undefined, "missing_token") + ) ); return; } @@ -101,8 +90,8 @@ export function authenticateJWT( new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(token, classifyJwtError(error)), - ), + buildAuthFailureDetails(token, classifyJwtError(error)) + ) ); } } @@ -129,11 +118,7 @@ export function requireKYC(skipVerification = false) { }; } -export function checkKycVerified( - req: Request, - _res: Response, - next: NextFunction, -): void { +export function checkKycVerified(req: Request, _res: Response, next: NextFunction): void { const user = (req as AuthenticatedRequest).user; if (!user) { next(new HttpError(401, "Authentication required")); diff --git a/src/middleware/error.middleware.ts b/src/middleware/error.middleware.ts index 0d1c176..0e53a13 100644 --- a/src/middleware/error.middleware.ts +++ b/src/middleware/error.middleware.ts @@ -4,21 +4,12 @@ import type { AppLogger } from "../observability/logger"; import { AppError, HttpError } from "../utils/http-error"; import type { AuthFailureDetails } from "../lib/auth-failure"; -export function notFoundMiddleware( - _req: Request, - _res: Response, - next: NextFunction -) { +export function notFoundMiddleware(_req: Request, _res: Response, next: NextFunction) { next(new HttpError(404, "Route not found.")); } export function createErrorMiddleware(logger: AppLogger) { - return ( - error: unknown, - req: Request, - res: Response, - _next: NextFunction, - ): void => { + return (error: unknown, req: Request, res: Response, _next: NextFunction): void => { if (error instanceof AppError || error instanceof HttpError) { res.status(error.statusCode).json({ success: false, diff --git a/src/middleware/ip-whitelist.middleware.ts b/src/middleware/ip-whitelist.middleware.ts index 5c632d5..3e0236a 100644 --- a/src/middleware/ip-whitelist.middleware.ts +++ b/src/middleware/ip-whitelist.middleware.ts @@ -17,4 +17,4 @@ export function ipWhitelistMiddleware(allowedCidrs: string[]) { next(); }; -} \ No newline at end of file +} diff --git a/src/middleware/rate-limit-wallet.middleware.ts b/src/middleware/rate-limit-wallet.middleware.ts index a9f2765..59443db 100644 --- a/src/middleware/rate-limit-wallet.middleware.ts +++ b/src/middleware/rate-limit-wallet.middleware.ts @@ -3,83 +3,83 @@ import type { AuthenticatedRequest } from "../types/auth"; import { HttpError } from "../utils/http-error"; interface WalletRateLimitEntry { - count: number; - windowStart: number; + count: number; + windowStart: number; } interface WalletRateLimitConfig { - windowMs: number; - maxRequests: number; + windowMs: number; + maxRequests: number; } const stores = new Map>(); function getStore(name: string): Map { - let store = stores.get(name); - if (!store) { - store = new Map(); - stores.set(name, store); - } - return store; + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + return store; } function getWalletAddress(req: Request): string | null { - const authReq = req as AuthenticatedRequest; - return authReq.user?.stellarAddress ?? null; + const authReq = req as AuthenticatedRequest; + return authReq.user?.stellarAddress ?? null; } export function createWalletRateLimiter(config: WalletRateLimitConfig, name: string) { - const store = getStore(name); + const store = getStore(name); - // Periodically clean up stale entries - setInterval(() => { - const now = Date.now(); - for (const [key, entry] of store) { - if (now - entry.windowStart >= config.windowMs) { - store.delete(key); - } - } - }, config.windowMs).unref(); + // Periodically clean up stale entries + setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (now - entry.windowStart >= config.windowMs) { + store.delete(key); + } + } + }, config.windowMs).unref(); - return (req: Request, res: Response, next: NextFunction): void => { - const wallet = getWalletAddress(req); + return (req: Request, res: Response, next: NextFunction): void => { + const wallet = getWalletAddress(req); - if (!wallet) { - next(new HttpError(401, "Authentication required for rate-limited endpoint.")); - return; - } + if (!wallet) { + next(new HttpError(401, "Authentication required for rate-limited endpoint.")); + return; + } - const now = Date.now(); - const entry = store.get(wallet); + const now = Date.now(); + const entry = store.get(wallet); - if (!entry || now - entry.windowStart >= config.windowMs) { - // Start a new window - store.set(wallet, { count: 1, windowStart: now }); - next(); - return; - } + if (!entry || now - entry.windowStart >= config.windowMs) { + // Start a new window + store.set(wallet, { count: 1, windowStart: now }); + next(); + return; + } - if (entry.count >= config.maxRequests) { - const retryAfterMs = config.windowMs - (now - entry.windowStart); - const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + if (entry.count >= config.maxRequests) { + const retryAfterMs = config.windowMs - (now - entry.windowStart); + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); - res.setHeader("Retry-After", String(retryAfterSeconds)); - res.status(429).json({ - success: false, - error: { - code: "RATE_LIMIT_EXCEEDED", - message: `Too many requests. Please wait ${retryAfterSeconds} seconds before retrying.`, - }, - }); - return; - } + res.setHeader("Retry-After", String(retryAfterSeconds)); + res.status(429).json({ + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + message: `Too many requests. Please wait ${retryAfterSeconds} seconds before retrying.`, + }, + }); + return; + } - entry.count++; - next(); - }; + entry.count++; + next(); + }; } // For testing: allow resetting stores export function resetRateLimitStores(): void { - stores.clear(); -} \ No newline at end of file + stores.clear(); +} diff --git a/src/middleware/rate-limit.middleware.ts b/src/middleware/rate-limit.middleware.ts index eac3ced..78a93d2 100644 --- a/src/middleware/rate-limit.middleware.ts +++ b/src/middleware/rate-limit.middleware.ts @@ -1,4 +1,5 @@ import rateLimit from "express-rate-limit"; +import type { Request as ExpressRequest } from "express"; import type { AppLogger } from "../observability/logger"; import { HttpError } from "../utils/http-error"; @@ -7,6 +8,7 @@ export interface RateLimitOptions { max: number; message?: string; code?: string; + keyGenerator?: (req: ExpressRequest) => string; } const DEFAULT_GLOBAL_LIMIT: RateLimitOptions = { @@ -16,20 +18,28 @@ const DEFAULT_GLOBAL_LIMIT: RateLimitOptions = { code: "RATE_LIMIT_EXCEEDED", }; -const DEFAULT_AUTH_LIMIT: RateLimitOptions = { - windowMs: 15 * 60 * 1000, - max: 10, - message: "Too many authentication attempts, please try again later.", - code: "AUTH_RATE_LIMIT_EXCEEDED", +const DEFAULT_CHALLENGE_LIMIT: RateLimitOptions = { + windowMs: 60 * 1000, + max: 5, + message: "Too many challenge requests, please try again later.", + code: "CHALLENGE_RATE_LIMIT_EXCEEDED", +}; + +const DEFAULT_VERIFY_LIMIT: RateLimitOptions = { + windowMs: 60 * 1000, + max: 20, + message: "Too many verification attempts, please try again later.", + code: "VERIFY_RATE_LIMIT_EXCEEDED", }; export function createRateLimitMiddleware( logger: AppLogger, - options: RateLimitOptions = DEFAULT_GLOBAL_LIMIT, + options: RateLimitOptions = DEFAULT_GLOBAL_LIMIT ) { const limiter = rateLimit({ windowMs: options.windowMs, max: options.max, + keyGenerator: options.keyGenerator, message: { success: false, error: { @@ -50,7 +60,7 @@ export function createRateLimitMiddleware( const error = new HttpError( 429, - nextOptions?.message ?? "Too many requests, please try again later.", + nextOptions?.message ?? "Too many requests, please try again later." ); next(error); @@ -60,8 +70,16 @@ export function createRateLimitMiddleware( return limiter; } +export function createChallengeRateLimitMiddleware(logger: AppLogger) { + return createRateLimitMiddleware(logger, DEFAULT_CHALLENGE_LIMIT); +} + +export function createVerifyRateLimitMiddleware(logger: AppLogger) { + return createRateLimitMiddleware(logger, DEFAULT_VERIFY_LIMIT); +} + export function createAuthRateLimitMiddleware(logger: AppLogger) { - return createRateLimitMiddleware(logger, DEFAULT_AUTH_LIMIT); + return createRateLimitMiddleware(logger, DEFAULT_VERIFY_LIMIT); } export function applyRateLimiters( @@ -70,7 +88,7 @@ export function applyRateLimiters( config?: { global?: Partial; auth?: Partial; - }, + } ) { const globalOptions: RateLimitOptions = { ...DEFAULT_GLOBAL_LIMIT, diff --git a/src/middleware/request-observability.middleware.ts b/src/middleware/request-observability.middleware.ts index cb8ab86..004b645 100644 --- a/src/middleware/request-observability.middleware.ts +++ b/src/middleware/request-observability.middleware.ts @@ -49,7 +49,7 @@ function resolveRequestId(requestIdHeader: string | string[] | undefined): strin } export function createRequestObservabilityMiddleware( - dependencies: RequestObservabilityDependencies, + dependencies: RequestObservabilityDependencies ) { return (req: Request, res: Response, next: NextFunction): void => { const requestId = resolveRequestId(req.header("x-request-id")); diff --git a/src/middleware/sanitize-input.middleware.ts b/src/middleware/sanitize-input.middleware.ts index 9e364e1..4593712 100644 --- a/src/middleware/sanitize-input.middleware.ts +++ b/src/middleware/sanitize-input.middleware.ts @@ -42,11 +42,7 @@ function sanitizeObject(obj: Record): Record { return sanitized; } -export function sanitizeInputMiddleware( - req: Request, - _res: Response, - next: NextFunction, -): void { +export function sanitizeInputMiddleware(req: Request, _res: Response, next: NextFunction): void { if (req.body && typeof req.body === "object" && !isBuffer(req.body)) { req.body = sanitizeObject(req.body); } diff --git a/src/middleware/validate.middleware.ts b/src/middleware/validate.middleware.ts index 0d204bd..461b42f 100644 --- a/src/middleware/validate.middleware.ts +++ b/src/middleware/validate.middleware.ts @@ -14,8 +14,8 @@ export function validateBody(schema: ObjectSchema) { new HttpError( 400, "Request validation failed.", - error.details.map((detail) => detail.message), - ), + error.details.map((detail) => detail.message) + ) ); return; } diff --git a/src/migrations/1712000000000-CreateWebhookSubscriptionsTable.ts b/src/migrations/1712000000000-CreateWebhookSubscriptionsTable.ts index a4e7474..e19cbcc 100644 --- a/src/migrations/1712000000000-CreateWebhookSubscriptionsTable.ts +++ b/src/migrations/1712000000000-CreateWebhookSubscriptionsTable.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class CreateWebhookSubscriptionsTable1712000000000 - implements MigrationInterface -{ +export class CreateWebhookSubscriptionsTable1712000000000 implements MigrationInterface { name = "CreateWebhookSubscriptionsTable1712000000000"; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/migrations/1713000000000-CreateSorobanEventLogsTable.ts b/src/migrations/1713000000000-CreateSorobanEventLogsTable.ts index 988a2e6..47f1c92 100644 --- a/src/migrations/1713000000000-CreateSorobanEventLogsTable.ts +++ b/src/migrations/1713000000000-CreateSorobanEventLogsTable.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class CreateSorobanEventLogsTable1713000000000 - implements MigrationInterface -{ +export class CreateSorobanEventLogsTable1713000000000 implements MigrationInterface { name = "CreateSorobanEventLogsTable1713000000000"; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/migrations/1714000000000-AddSorobanEventLogsIndex.ts b/src/migrations/1714000000000-AddSorobanEventLogsIndex.ts index 077f91e..c235db5 100644 --- a/src/migrations/1714000000000-AddSorobanEventLogsIndex.ts +++ b/src/migrations/1714000000000-AddSorobanEventLogsIndex.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class AddSorobanEventLogsIndex1714000000000 - implements MigrationInterface -{ +export class AddSorobanEventLogsIndex1714000000000 implements MigrationInterface { name = "AddSorobanEventLogsIndex1714000000000"; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts b/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts index a5dab1f..67ab896 100644 --- a/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts +++ b/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class AddInvestmentPaymentVerification1731700000000 - implements MigrationInterface -{ +export class AddInvestmentPaymentVerification1731700000000 implements MigrationInterface { name = "AddInvestmentPaymentVerification1731700000000"; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/migrations/1731800000000-AddTransactionInvoiceAndInvestmentLinks.ts b/src/migrations/1731800000000-AddTransactionInvoiceAndInvestmentLinks.ts index 2435b97..b874875 100644 --- a/src/migrations/1731800000000-AddTransactionInvoiceAndInvestmentLinks.ts +++ b/src/migrations/1731800000000-AddTransactionInvoiceAndInvestmentLinks.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class AddTransactionInvoiceAndInvestmentLinks1731800000000 - implements MigrationInterface -{ +export class AddTransactionInvoiceAndInvestmentLinks1731800000000 implements MigrationInterface { name = "AddTransactionInvoiceAndInvestmentLinks1731800000000"; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/migrations/1731900000001-AddUserKycVerifiedFlag.ts b/src/migrations/1731900000001-AddUserKycVerifiedFlag.ts index 5206fca..44cae7c 100644 --- a/src/migrations/1731900000001-AddUserKycVerifiedFlag.ts +++ b/src/migrations/1731900000001-AddUserKycVerifiedFlag.ts @@ -10,7 +10,7 @@ export class AddUserKycVerifiedFlag1731900000001 implements MigrationInterface { name: "is_kyc_verified", type: "boolean", default: false, - }), + }) ); await queryRunner.query(` UPDATE "users" diff --git a/src/migrations/1732100000000-CreateWebhookDeliveryLogsTable.ts b/src/migrations/1732100000000-CreateWebhookDeliveryLogsTable.ts index 0804b31..5009797 100644 --- a/src/migrations/1732100000000-CreateWebhookDeliveryLogsTable.ts +++ b/src/migrations/1732100000000-CreateWebhookDeliveryLogsTable.ts @@ -3,8 +3,12 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class CreateWebhookDeliveryLogsTable1732100000000 implements MigrationInterface { name = "CreateWebhookDeliveryLogsTable1732100000000"; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "webhook_delivery_logs" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "subscription_id" uuid NOT NULL, "event_type" character varying(100) NOT NULL, "attempt" integer NOT NULL, "response_status" integer, "delivered" boolean NOT NULL, "error_message" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_webhook_delivery_logs" PRIMARY KEY ("id"), CONSTRAINT "FK_webhook_delivery_logs_subscription" FOREIGN KEY ("subscription_id") REFERENCES "webhook_subscriptions"("id") ON DELETE CASCADE);`); - await queryRunner.query(`CREATE INDEX "idx_webhook_delivery_logs_subscription_id" ON "webhook_delivery_logs" ("subscription_id")`); + await queryRunner.query( + `CREATE TABLE "webhook_delivery_logs" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "subscription_id" uuid NOT NULL, "event_type" character varying(100) NOT NULL, "attempt" integer NOT NULL, "response_status" integer, "delivered" boolean NOT NULL, "error_message" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_webhook_delivery_logs" PRIMARY KEY ("id"), CONSTRAINT "FK_webhook_delivery_logs_subscription" FOREIGN KEY ("subscription_id") REFERENCES "webhook_subscriptions"("id") ON DELETE CASCADE);` + ); + await queryRunner.query( + `CREATE INDEX "idx_webhook_delivery_logs_subscription_id" ON "webhook_delivery_logs" ("subscription_id")` + ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "public"."idx_webhook_delivery_logs_subscription_id"`); diff --git a/src/migrations/1732200000000-AddWebhookDeliveryEventId.ts b/src/migrations/1732200000000-AddWebhookDeliveryEventId.ts index cf83558..082bd52 100644 --- a/src/migrations/1732200000000-AddWebhookDeliveryEventId.ts +++ b/src/migrations/1732200000000-AddWebhookDeliveryEventId.ts @@ -5,19 +5,15 @@ export class AddWebhookDeliveryEventId1732200000000 implements MigrationInterfac public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `ALTER TABLE "webhook_delivery_logs" ADD "event_id" character varying(200)`, + `ALTER TABLE "webhook_delivery_logs" ADD "event_id" character varying(200)` ); await queryRunner.query( - `CREATE INDEX "idx_webhook_delivery_logs_event_id" ON "webhook_delivery_logs" ("event_id")`, + `CREATE INDEX "idx_webhook_delivery_logs_event_id" ON "webhook_delivery_logs" ("event_id")` ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DROP INDEX "public"."idx_webhook_delivery_logs_event_id"`, - ); - await queryRunner.query( - `ALTER TABLE "webhook_delivery_logs" DROP COLUMN "event_id"`, - ); + await queryRunner.query(`DROP INDEX "public"."idx_webhook_delivery_logs_event_id"`); + await queryRunner.query(`ALTER TABLE "webhook_delivery_logs" DROP COLUMN "event_id"`); } } diff --git a/src/models/AuthChallenge.model.ts b/src/models/AuthChallenge.model.ts index 047e75f..98cf2ee 100644 --- a/src/models/AuthChallenge.model.ts +++ b/src/models/AuthChallenge.model.ts @@ -1,10 +1,4 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - PrimaryGeneratedColumn, -} from "typeorm"; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; @Entity("auth_challenges") @Index("idx_auth_challenges_address_nonce", ["stellarAddress", "nonceHash"], { diff --git a/src/models/KYCVerification.model.ts b/src/models/KYCVerification.model.ts index 57788c4..4a53796 100644 --- a/src/models/KYCVerification.model.ts +++ b/src/models/KYCVerification.model.ts @@ -1,11 +1,4 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, - Index, -} from "typeorm"; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from "typeorm"; import { KYCStatus, KYCVerificationType } from "../types/enums"; @Entity("kyc_verifications") diff --git a/src/models/Notification.model.ts b/src/models/Notification.model.ts index ed57a50..af2833a 100644 --- a/src/models/Notification.model.ts +++ b/src/models/Notification.model.ts @@ -1,11 +1,4 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, - Index, -} from "typeorm"; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from "typeorm"; import { NotificationType } from "../types/enums"; @Entity("notifications") diff --git a/src/models/SorobanEventLog.model.ts b/src/models/SorobanEventLog.model.ts index d0de18c..10c4450 100644 --- a/src/models/SorobanEventLog.model.ts +++ b/src/models/SorobanEventLog.model.ts @@ -1,10 +1,4 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - PrimaryGeneratedColumn, -} from "typeorm"; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; @Entity("soroban_event_logs") export class SorobanEventLog { diff --git a/src/models/Transaction.model.ts b/src/models/Transaction.model.ts index c065502..e4e134b 100644 --- a/src/models/Transaction.model.ts +++ b/src/models/Transaction.model.ts @@ -1,3 +1,4 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from "typeorm"; import { Entity, PrimaryGeneratedColumn, diff --git a/src/models/WebhookDeliveryLog.model.ts b/src/models/WebhookDeliveryLog.model.ts index 891a2ee..299603a 100644 --- a/src/models/WebhookDeliveryLog.model.ts +++ b/src/models/WebhookDeliveryLog.model.ts @@ -3,11 +3,17 @@ import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from @Entity("webhook_delivery_logs") export class WebhookDeliveryLog { @PrimaryGeneratedColumn("uuid") id!: string; - @Column({ name: "subscription_id", type: "uuid" }) @Index("idx_webhook_delivery_logs_subscription_id") subscriptionId!: string; + @Column({ name: "subscription_id", type: "uuid" }) + @Index("idx_webhook_delivery_logs_subscription_id") + subscriptionId!: string; @Column({ name: "event_type", type: "varchar", length: 100 }) eventType!: string; - @Column({ name: "event_id", type: "varchar", length: 200, nullable: true }) @Index("idx_webhook_delivery_logs_event_id") eventId!: string | null; + @Column({ name: "event_id", type: "varchar", length: 200, nullable: true }) + @Index("idx_webhook_delivery_logs_event_id") + eventId!: string | null; @Column({ name: "attempt", type: "integer" }) attempt!: number; - @Column({ name: "response_status", type: "integer", nullable: true }) responseStatus!: number | null; + @Column({ name: "response_status", type: "integer", nullable: true }) responseStatus!: + | number + | null; @Column({ name: "delivered", type: "boolean" }) delivered!: boolean; @Column({ name: "error_message", type: "text", nullable: true }) errorMessage!: string | null; @CreateDateColumn({ name: "created_at", type: "timestamptz" }) createdAt!: Date; diff --git a/src/observability/logger.ts b/src/observability/logger.ts index f7e71f8..305bb6c 100644 --- a/src/observability/logger.ts +++ b/src/observability/logger.ts @@ -37,9 +37,7 @@ class WinstonAppLogger implements AppLogger { function createBaseLogger(): winston.Logger { return winston.createLogger({ - level: - process.env.LOG_LEVEL ?? - (process.env.NODE_ENV === "test" ? "silent" : "info"), + level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === "test" ? "silent" : "info"), defaultMeta: { service: "stellarsettle-api", }, @@ -47,7 +45,7 @@ function createBaseLogger(): winston.Logger { redactionFormat(), winston.format.timestamp(), winston.format.errors({ stack: true }), - winston.format.json(), + winston.format.json() ), transports: [new winston.transports.Console()], }); diff --git a/src/observability/metrics.ts b/src/observability/metrics.ts index 8bdf209..b7c863d 100644 --- a/src/observability/metrics.ts +++ b/src/observability/metrics.ts @@ -25,7 +25,7 @@ function escapeLabelValue(value: string): string { function buildLabelSet(labels: RequestMetricLabels): string { return `method="${escapeLabelValue(labels.method)}",route="${escapeLabelValue( - labels.route, + labels.route )}",status_class="${escapeLabelValue(labels.statusClass)}"`; } @@ -36,6 +36,7 @@ function buildMetricKey(labels: RequestMetricLabels): string { export class MetricsRegistry { private readonly requestCounters = new Map(); private readonly requestDurationHistograms = new Map(); + private readonly customCounters = new Map(); recordHttpRequest(input: RequestMetricLabels & { durationMs: number }): void { const labels: RequestMetricLabels = { @@ -80,13 +81,13 @@ export class MetricsRegistry { for (const metric of this.requestCounters.values()) { lines.push( - `stellarsettle_http_requests_total{${buildLabelSet(metric.labels)}} ${metric.value}`, + `stellarsettle_http_requests_total{${buildLabelSet(metric.labels)}} ${metric.value}` ); } lines.push( "# HELP stellarsettle_http_request_duration_ms HTTP request duration in milliseconds.", - "# TYPE stellarsettle_http_request_duration_ms histogram", + "# TYPE stellarsettle_http_request_duration_ms histogram" ); for (const metric of this.requestDurationHistograms.values()) { @@ -96,28 +97,46 @@ export class MetricsRegistry { cumulativeCount += metric.bucketCounts[index]; lines.push( `stellarsettle_http_request_duration_ms_bucket{${buildLabelSet( - metric.labels, - )},le="${HTTP_DURATION_BUCKETS_MS[index]}"} ${cumulativeCount}`, + metric.labels + )},le="${HTTP_DURATION_BUCKETS_MS[index]}"} ${cumulativeCount}` ); } lines.push( `stellarsettle_http_request_duration_ms_bucket{${buildLabelSet( - metric.labels, + metric.labels )},le="+Inf"} ${metric.count}`, `stellarsettle_http_request_duration_ms_sum{${buildLabelSet(metric.labels)}} ${metric.sum}`, - `stellarsettle_http_request_duration_ms_count{${buildLabelSet(metric.labels)}} ${metric.count}`, + `stellarsettle_http_request_duration_ms_count{${buildLabelSet(metric.labels)}} ${metric.count}` + ); + } + + for (const [key, value] of this.customCounters.entries()) { + lines.push( + `# HELP ${key}_total Custom counter.`, + `# TYPE ${key}_total counter`, + `${key}_total ${value}` ); } lines.push( "# HELP stellarsettle_process_uptime_seconds Process uptime in seconds.", "# TYPE stellarsettle_process_uptime_seconds gauge", - `stellarsettle_process_uptime_seconds ${process.uptime()}`, + `stellarsettle_process_uptime_seconds ${process.uptime()}` ); return `${lines.join("\n")}\n`; } + + increment(name: string, labels?: Record): void { + const key = labels + ? `${name}{${Object.entries(labels) + .map(([k, v]) => `${k}="${v}"`) + .join(",")}}` + : name; + const current = this.customCounters.get(key) ?? 0; + this.customCounters.set(key, current + 1); + } } export function getMetricsContentType(): string { diff --git a/src/observability/redaction-formatter.ts b/src/observability/redaction-formatter.ts index 101994b..f0256b8 100644 --- a/src/observability/redaction-formatter.ts +++ b/src/observability/redaction-formatter.ts @@ -2,11 +2,9 @@ import winston from "winston"; const STELLAR_SECRET_KEY_PATTERN = /S[A-Z0-9]{55}/g; -const STELLAR_SECRET_KEY_REDACTED = - "S*******************************************************"; +const STELLAR_SECRET_KEY_REDACTED = "S*******************************************************"; -const JWT_PATTERN = - /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g; +const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g; const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9_\-.~+/]+=*/g; diff --git a/src/routes/admin/admin.routes.ts b/src/routes/admin/admin.routes.ts index 6902cdf..59ca7df 100644 --- a/src/routes/admin/admin.routes.ts +++ b/src/routes/admin/admin.routes.ts @@ -43,4 +43,4 @@ export function createAdminRouter({ }); return router; -} \ No newline at end of file +} diff --git a/src/routes/admin/approve-kyc.ts b/src/routes/admin/approve-kyc.ts index 2b9f3af..45c706f 100644 --- a/src/routes/admin/approve-kyc.ts +++ b/src/routes/admin/approve-kyc.ts @@ -10,7 +10,11 @@ interface ApproveKYCBody { reviewerId: string; } -export async function approveKYC(req: Request, res: Response, dataSource: DataSource) { +export async function approveKYC( + req: Request, + res: Response, + dataSource: DataSource +) { try { const adminKey = req.headers["x-admin-key"]; if (adminKey !== process.env.ADMIN_API_KEY) { diff --git a/src/routes/admin/reject-invoice.ts b/src/routes/admin/reject-invoice.ts index 4e6aec7..42fce08 100644 --- a/src/routes/admin/reject-invoice.ts +++ b/src/routes/admin/reject-invoice.ts @@ -26,7 +26,7 @@ interface RejectInvoiceParams { export async function rejectInvoice( req: Request, res: Response, - invoiceService: InvoiceService, + invoiceService: InvoiceService ) { try { const adminKey = req.headers["x-admin-key"]; diff --git a/src/routes/admin/reject-kyc.ts b/src/routes/admin/reject-kyc.ts index 75ac4a1..2071a1d 100644 --- a/src/routes/admin/reject-kyc.ts +++ b/src/routes/admin/reject-kyc.ts @@ -11,7 +11,11 @@ interface RejectKYCBody { rejectionReason: string; } -export async function rejectKYC(req: Request, res: Response, dataSource: DataSource) { +export async function rejectKYC( + req: Request, + res: Response, + dataSource: DataSource +) { try { const adminKey = req.headers["x-admin-key"]; if (adminKey !== process.env.ADMIN_API_KEY) { diff --git a/src/routes/admin/revoke-kyc.ts b/src/routes/admin/revoke-kyc.ts index 84716ad..f0600c2 100644 --- a/src/routes/admin/revoke-kyc.ts +++ b/src/routes/admin/revoke-kyc.ts @@ -20,7 +20,11 @@ interface RevokeKYCBody { * them able to re-submit. Only an approved user can be revoked — revoking * anything else would be a no-op that still wrote an audit entry. */ -export async function revokeKYC(req: Request, res: Response, dataSource: DataSource) { +export async function revokeKYC( + req: Request, + res: Response, + dataSource: DataSource +) { try { const adminKey = req.headers["x-admin-key"]; if (adminKey !== process.env.ADMIN_API_KEY) { diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 4f2b507..ea78eb9 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -1,11 +1,28 @@ -import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; +import { + Router, + type NextFunction, + type Request, + type RequestHandler, + type Response, + type ErrorRequestHandler, +} from "express"; import Joi from "joi"; import { createAuthController } from "../controllers/auth.controller"; import { createAuthMiddleware } from "../middleware/auth.middleware"; import { validateBody } from "../middleware/validate.middleware"; -import { createAuthRateLimitMiddleware } from "../middleware/rate-limit.middleware"; +import { + createChallengeRateLimitMiddleware, + createVerifyRateLimitMiddleware, +} from "../middleware/rate-limit.middleware"; +import { createCircuitBreaker } from "../lib/circuit-breaker"; import type { AuthService } from "../services/auth.service"; import type { AppLogger } from "../observability/logger"; +import { HttpError } from "../utils/http-error"; + +// Strict schemas: enforce Stellar G... format hint, length bounds, and sanitized inputs. +const STELLAR_PUBLIC_KEY_PATTERN = /^G[A-Z2-7]{55}$/; +const NONCE_PATTERN = /^[A-Za-z0-9:_-]+$/; +const SIGNATURE_PATTERN = /^[A-Za-z0-9+/=:_\-.]+$/; type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise | void; @@ -24,7 +41,7 @@ const verifySchema = Joi.object({ function wrapAuthHandler( routeName: string, handler: AsyncRouteHandler, - logger: AppLogger, + logger: AppLogger ): RequestHandler { return async (req, res, next) => { try { @@ -52,40 +69,148 @@ function markAuthRouteBase(): RequestHandler { function noStoreAuthResponse(): RequestHandler { return (_req, res, next) => { res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + next(); + }; +} + +function extractIdempotencyKey(req: Request): string | null { + const key = req.headers["idempotency-key"] as string | undefined; + return key || null; +} + +function createIdempotencyMiddleware() { + const cache = new Map< + string, + { status: number; body: Record; expiresAt: number } + >(); + const TTL_MS = 60 * 60 * 1000; + + setInterval( + () => { + const now = Date.now(); + for (const [key, value] of cache.entries()) { + if (value.expiresAt < now) { + cache.delete(key); + } + } + }, + 5 * 60 * 1000 + ); + + return (req: Request, res: Response, next: NextFunction) => { + const key = extractIdempotencyKey(req); + if (!key) { + return next(); + } + + const cached = cache.get(key); + if (cached) { + res.setHeader("X-Idempotency-Replay", "true"); + return res.status(cached.status).json(cached.body); + } + + const originalJson = res.json.bind(res); + res.json = (body: Record) => { + if (res.statusCode < 400) { + cache.set(key, { status: res.statusCode, body, expiresAt: Date.now() + TTL_MS }); + } + return originalJson(body); + }; + next(); }; } +function normalizeErrorResponse(): ErrorRequestHandler { + return (err: Error, req: Request, res: Response, _next: NextFunction): void => { + if (err instanceof HttpError) { + res.status(err.statusCode).json({ + success: false, + error: { + code: err.code ?? "INTERNAL_ERROR", + message: err.message, + details: err.details, + }, + requestId: req.headers["x-request-id"], + }); + return; + } + + res.status(500).json({ + success: false, + error: { + code: "INTERNAL_ERROR", + message: "An unexpected error occurred", + }, + requestId: req.headers["x-request-id"], + }); + }; +} + export function createAuthRouter(authService: AuthService, logger: AppLogger): Router { const router = Router(); const controller = createAuthController(authService); const authMiddleware = createAuthMiddleware(authService); - // `/challenge` and `/verify` are unauthenticated by design: the wallet - // signature is the auth check, so they need their own abuse protection. - const authRateLimiter = createAuthRateLimitMiddleware(logger); + + const challengeRateLimiter = createChallengeRateLimitMiddleware(logger); + const verifyRateLimiter = createVerifyRateLimitMiddleware(logger); + const idempotencyMiddleware = createIdempotencyMiddleware(); + const circuitBreaker = createCircuitBreaker({ failureThreshold: 5, timeout: 30000 }); + + const withCircuitBreaker = (handler: RequestHandler): RequestHandler => { + return async (req, res, next) => { + try { + await circuitBreaker.execute(async () => { + await new Promise((resolve, reject) => { + handler(req, res, (err) => (err ? reject(err) : resolve())); + }); + }); + } catch (error) { + if (error instanceof Error && error.message === "Circuit breaker is open") { + logger.warn("Circuit breaker open for auth route", { route: req.path }); + return next( + new HttpError(503, "Service temporarily unavailable", "CIRCUIT_BREAKER_OPEN") + ); + } + next(error); + } + }; + }; + + const withCircuitBreakerAndWrap = ( + routeName: string, + handler: AsyncRouteHandler + ): RequestHandler => { + return withCircuitBreaker(wrapAuthHandler(routeName, handler, logger)); + }; router.use(markAuthRouteBase()); router.use(noStoreAuthResponse()); + router.use(idempotencyMiddleware); router.post( "/challenge", - authRateLimiter, + challengeRateLimiter, validateBody(challengeSchema), - wrapAuthHandler("auth.challenge", controller.challenge as AsyncRouteHandler, logger), + withCircuitBreakerAndWrap("auth.challenge", controller.challenge as AsyncRouteHandler) ); router.post( "/verify", - authRateLimiter, + verifyRateLimiter, validateBody(verifySchema), - wrapAuthHandler("auth.verify", controller.verify as AsyncRouteHandler, logger), + withCircuitBreakerAndWrap("auth.verify", controller.verify as AsyncRouteHandler) ); router.get( "/me", authMiddleware, - wrapAuthHandler("auth.me", controller.me as AsyncRouteHandler, logger), + wrapAuthHandler("auth.me", controller.me as AsyncRouteHandler, logger) ); + router.use(normalizeErrorResponse()); + return router; -} \ No newline at end of file +} diff --git a/src/routes/investment.routes.ts b/src/routes/investment.routes.ts index ed27df4..53985ef 100644 --- a/src/routes/investment.routes.ts +++ b/src/routes/investment.routes.ts @@ -17,7 +17,7 @@ export interface InvestmentRouterDependencies { // Per-wallet rate limit: max 10 investment submissions per 60 seconds const investmentRateLimiter = createWalletRateLimiter( { windowMs: 60_000, maxRequests: 10 }, - "investment-create", + "investment-create" ); export function createInvestmentRouter({ @@ -37,7 +37,13 @@ export function createInvestmentRouter({ : []; // POST /api/v1/investments - Create a new investment commitment - router.post("/", authMiddleware, ...pauseGuard, investmentRateLimiter, controller.createInvestment); + router.post( + "/", + authMiddleware, + ...pauseGuard, + investmentRateLimiter, + controller.createInvestment + ); // GET /api/v1/investments/dashboard - Investor portfolio aggregate router.get("/dashboard", authMiddleware, controller.getDashboard); diff --git a/src/routes/investments/create.ts b/src/routes/investments/create.ts index 9b63feb..3ce5b9d 100644 --- a/src/routes/investments/create.ts +++ b/src/routes/investments/create.ts @@ -21,4 +21,4 @@ export async function createInvestment(req: AuthenticatedRequest, res: Response) }, }); } -} \ No newline at end of file +} diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index d387032..8e68815 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -23,7 +23,9 @@ const createInvoiceSchema = Joi.object({ amount: Joi.string() .required() .pattern(/^\d+(\.\d{1,4})?$/) - .messages({ "string.pattern.base": "amount must be a decimal number with max 4 decimal places" }), + .messages({ + "string.pattern.base": "amount must be a decimal number with max 4 decimal places", + }), discountRate: Joi.string() .required() .pattern(/^\d+(\.\d{1,2})?$/) @@ -34,7 +36,9 @@ const createInvoiceSchema = Joi.object({ } return value; }) - .messages({ "any.invalid": "discountRate must be a percentage (0-100) with max 2 decimal places" }), + .messages({ + "any.invalid": "discountRate must be a percentage (0-100) with max 2 decimal places", + }), dueDate: Joi.date().iso().required(), ipfsHash: Joi.string().optional().trim().max(128), riskScore: Joi.string() @@ -47,7 +51,9 @@ const createInvoiceSchema = Joi.object({ } return value; }) - .messages({ "any.invalid": "riskScore must be a percentage (0-100) with max 2 decimal places" }), + .messages({ + "any.invalid": "riskScore must be a percentage (0-100) with max 2 decimal places", + }), }); const updateInvoiceSchema = Joi.object({ @@ -55,12 +61,16 @@ const updateInvoiceSchema = Joi.object({ amount: Joi.string() .optional() .pattern(/^\d+(\.\d{1,4})?$/) - .messages({ "string.pattern.base": "amount must be a decimal number with max 4 decimal places" }), + .messages({ + "string.pattern.base": "amount must be a decimal number with max 4 decimal places", + }), discountRate: Joi.string() .optional() .pattern(/^\d+(\.\d{1,2})?$/) .max(100) - .messages({ "string.pattern.base": "discountRate must be a percentage (0-100) with max 2 decimal places" }), + .messages({ + "string.pattern.base": "discountRate must be a percentage (0-100) with max 2 decimal places", + }), dueDate: Joi.date().iso().optional(), riskScore: Joi.string() .optional() @@ -89,12 +99,12 @@ const getInvoicesQuerySchema = Joi.object({ const calculateTermsSchema = Joi.object({ faceValue: Joi.alternatives() - .try( - Joi.string().pattern(/^\d+(\.\d{1,4})?$/), - Joi.number().positive(), - ) + .try(Joi.string().pattern(/^\d+(\.\d{1,4})?$/), Joi.number().positive()) .required() - .messages({ "alternatives.match": "faceValue must be a positive number or decimal string with max 4 decimal places" }), + .messages({ + "alternatives.match": + "faceValue must be a positive number or decimal string with max 4 decimal places", + }), dueDate: Joi.date().iso().required(), discountBps: Joi.number().integer().min(0).max(10000).required(), platformFeeBps: Joi.number().integer().min(0).max(10000).optional().default(0), @@ -112,9 +122,7 @@ function validateBody(schema: Joi.Schema) { }); if (error) { - return next( - new HttpError(400, `Invalid request: ${error.message}`) - ); + return next(new HttpError(400, `Invalid request: ${error.message}`)); } req.body = value; @@ -130,24 +138,19 @@ function validateQuery(schema: Joi.Schema) { }); if (error) { - return next( - new HttpError(400, `Invalid query parameters: ${error.message}`) - ); + return next(new HttpError(400, `Invalid query parameters: ${error.message}`)); } // Replace req.query with validated value // In Express, req.query is a getter/setter by default, but we can override it // if we use the default query parser. - Object.keys(req.query).forEach(key => delete req.query[key]); + Object.keys(req.query).forEach((key) => delete req.query[key]); Object.assign(req.query, value); next(); }; } -export function createInvoiceRouter({ - invoiceService, - config, -}: InvoiceRouterDependencies): Router { +export function createInvoiceRouter({ invoiceService, config }: InvoiceRouterDependencies): Router { const router = Router(); const controller = createInvoiceController(invoiceService); @@ -185,18 +188,13 @@ export function createInvoiceRouter({ // Per-wallet rate limit: max 5 invoice publishes per 60 seconds const publishRateLimiter = createWalletRateLimiter( { windowMs: 60_000, maxRequests: 5 }, - "invoice-publish", + "invoice-publish" ); // ============ INVOICE CRUD ENDPOINTS ============ // GET /api/v1/invoices - List invoices for authenticated seller - router.get( - "/", - authenticateJWT, - validateQuery(getInvoicesQuerySchema), - controller.getInvoices, - ); + router.get("/", authenticateJWT, validateQuery(getInvoicesQuerySchema), controller.getInvoices); // POST /api/v1/invoices - Create new invoice router.post( @@ -204,7 +202,7 @@ export function createInvoiceRouter({ authenticateJWT, kycGating, validateBody(createInvoiceSchema), - controller.createInvoice, + controller.createInvoice ); // POST /api/v1/invoices/batch-publish - Publish several drafts atomically. @@ -216,7 +214,7 @@ export function createInvoiceRouter({ kycGating, publishRateLimiter, validateBody(batchPublishSchema), - controller.batchPublishInvoices, + controller.batchPublishInvoices ); // GET /api/v1/invoices/:id - Get single invoice @@ -228,7 +226,7 @@ export function createInvoiceRouter({ authenticateJWT, kycGating, validateBody(updateInvoiceSchema), - controller.updateInvoice, + controller.updateInvoice ); // DELETE /api/v1/invoices/:id - Delete invoice @@ -240,7 +238,7 @@ export function createInvoiceRouter({ authenticateJWT, kycGating, publishRateLimiter, - controller.publishInvoice, + controller.publishInvoice ); // POST /api/v1/invoices/:id/document - Upload document @@ -250,29 +248,17 @@ export function createInvoiceRouter({ authenticateJWT, kycGating, upload.single("document"), - controller.uploadDocument, + controller.uploadDocument ); // GET /api/v1/invoices/:id/tokens - Get invoice token holders - router.get( - "/:id/tokens", - authenticateJWT, - controller.getInvoiceTokenHolders, - ); + router.get("/:id/tokens", authenticateJWT, controller.getInvoiceTokenHolders); // GET /api/v1/invoices/:id/escrow - Get invoice escrow status - router.get( - "/:id/escrow", - authenticateJWT, - controller.getInvoiceEscrowStatus, - ); + router.get("/:id/escrow", authenticateJWT, controller.getInvoiceEscrowStatus); // POST /api/v1/invoices/calculate-terms - Calculate invoice discounting terms, fees, and APR - router.post( - "/calculate-terms", - validateBody(calculateTermsSchema), - controller.calculateTerms, - ); + router.post("/calculate-terms", validateBody(calculateTermsSchema), controller.calculateTerms); return router; } diff --git a/src/routes/invoices/publish.ts b/src/routes/invoices/publish.ts index 8e6ea80..f9f6873 100644 --- a/src/routes/invoices/publish.ts +++ b/src/routes/invoices/publish.ts @@ -21,4 +21,4 @@ export async function publishInvoice(req: AuthenticatedRequest, res: Response) { }, }); } -} \ No newline at end of file +} diff --git a/src/routes/kyc.routes.ts b/src/routes/kyc.routes.ts index 6852980..0eb83ee 100644 --- a/src/routes/kyc.routes.ts +++ b/src/routes/kyc.routes.ts @@ -7,7 +7,11 @@ import type { AuthService } from "../services/auth.service"; export function createKycWebhookRouter(service: KycService): Router { const router = Router(); const controller = createKycController(service); - router.post("/webhook", express.raw({ type: "application/json", limit: "256kb" }), controller.webhook); + router.post( + "/webhook", + express.raw({ type: "application/json", limit: "256kb" }), + controller.webhook + ); return router; } diff --git a/src/routes/marketplace.routes.ts b/src/routes/marketplace.routes.ts index 2674395..02224ae 100644 --- a/src/routes/marketplace.routes.ts +++ b/src/routes/marketplace.routes.ts @@ -16,4 +16,4 @@ export function createMarketplaceRouter({ router.get("/invoices", controller.getInvoices); return router; -} \ No newline at end of file +} diff --git a/src/routes/notification.routes.ts b/src/routes/notification.routes.ts index f8e5b11..5c583cc 100644 --- a/src/routes/notification.routes.ts +++ b/src/routes/notification.routes.ts @@ -6,7 +6,7 @@ import type { NotificationService } from "../services/notification.service"; export function createNotificationRouter( notificationService: NotificationService, - authService: AuthService, + authService: AuthService ): Router { const router = Router(); const controller = createNotificationController(notificationService); @@ -28,4 +28,4 @@ export function createNotificationRouter( router.patch("/:id/read", controller.markRead); return router; -} \ No newline at end of file +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 21fdf0a..4a47b8c 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -7,12 +7,16 @@ import { AuthChallenge } from "../models/AuthChallenge.model"; import { User } from "../models/User.model"; import type { PublicUser } from "../types/auth"; import { HttpError } from "../utils/http-error"; +import { buildAuthFailureDetails, classifyJwtError } from "../lib/auth-failure"; +import { AppError, HttpError } from "../utils/http-error"; +import { logger } from "../observability/logger"; import { buildAuthFailureDetails, classifyJwtError, } from "../lib/auth-failure"; import type { AppLogger } from "../observability/logger"; import { buildWalletChallenge } from "../utils/stellar-challenge"; +import { MetricsRegistry } from "../observability/metrics"; interface ChallengeRecord { id: string; @@ -37,6 +41,14 @@ interface CreateChallengeRecordInput { export interface UserRepositoryContract { findById(id: string): Promise; findByStellarAddress(stellarAddress: string): Promise; + findByEmail(email: string): Promise; + findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }): Promise; + count(options?: { cursor?: string }): Promise; save(user: Partial): Promise; } @@ -44,9 +56,11 @@ export interface ChallengeRepositoryContract { create(input: CreateChallengeRecordInput): Promise; findByAddressAndNonceHash( stellarAddress: string, - nonceHash: string, + nonceHash: string ): Promise; consume(id: string, consumedAt: Date): Promise; + deleteExpired(before: Date): Promise; + countByStatus(status: "active" | "consumed" | "expired"): Promise; } interface AuthTokenPayload extends JwtPayload { @@ -59,6 +73,7 @@ export interface AuthServiceDependencies { challengeRepository: ChallengeRepositoryContract; config: Pick & { serverKeypair?: Keypair }; logger?: AppLogger; + metrics?: MetricsRegistry; } export interface ChallengeResponse { @@ -90,6 +105,7 @@ export class AuthService { private readonly config: Pick; private readonly logger?: AppLogger; private readonly serverKeypair?: Keypair; + private readonly metrics?: MetricsRegistry; constructor(dependencies: AuthServiceDependencies) { this.userRepository = dependencies.userRepository; @@ -97,6 +113,14 @@ export class AuthService { this.config = dependencies.config; this.logger = dependencies.logger; this.serverKeypair = dependencies.config.serverKeypair; + this.metrics = dependencies.metrics; + } + + private recordChallengeMetric( + status: "created" | "verified" | "expired" | "failed" | "reused", + wallet: string + ): void { + this.metrics?.increment("auth_challenge_total", { status, wallet: wallet.slice(0, 8) + "..." }); } async createChallenge(publicKey: string): Promise { @@ -112,7 +136,7 @@ export class AuthService { ({ nonce } = buildWalletChallenge( sanitizedKey, this.config.stellar.networkPassphrase, - this.serverKeypair, + this.serverKeypair )); } else { nonce = crypto.randomBytes(32).toString("hex"); @@ -136,6 +160,7 @@ export class AuthService { issuedAt, expiresAt, }); + this.recordChallengeMetric("created", sanitizedKey); } catch (error) { this.logger?.error("Failed to persist challenge", { error, stellarAddress: sanitizedKey }); throw new HttpError(500, "Failed to create challenge."); @@ -158,9 +183,7 @@ export class AuthService { } } - async verifyChallenge( - input: VerifyChallengeInput, - ): Promise { + async verifyChallenge(input: VerifyChallengeInput): Promise { try { const sanitizedKey = input.publicKey.trim(); const sanitizedNonce = input.nonce.trim(); @@ -179,7 +202,7 @@ export class AuthService { try { challenge = await this.challengeRepository.findByAddressAndNonceHash( sanitizedKey, - hashNonce(sanitizedNonce), + hashNonce(sanitizedNonce) ); } catch (error) { this.logger?.error("Failed to fetch challenge", { error, stellarAddress: sanitizedKey }); @@ -199,6 +222,7 @@ export class AuthService { } if (challenge.expiresAt.getTime() <= Date.now()) { + this.recordChallengeMetric("expired", sanitizedKey); throw new HttpError(401, "Challenge expired."); } @@ -224,6 +248,7 @@ export class AuthService { if (!isValid) { this.logger?.warn("Invalid challenge signature", { stellarAddress: sanitizedKey }); + this.recordChallengeMetric("failed", sanitizedKey); throw new HttpError(401, "Invalid signature."); } @@ -239,6 +264,8 @@ export class AuthService { throw new HttpError(401, "Challenge already used."); } + this.recordChallengeMetric("verified", sanitizedKey); + let user: User; try { user = await this.upsertUser(sanitizedKey); @@ -253,7 +280,9 @@ export class AuthService { const decoded = jwt.decode(token) as { iat?: number; exp?: number } | null; this.logger?.info("jwt.issued", { wallet: publicUser.stellarAddress, - issued_at: decoded?.iat ? new Date(decoded.iat * 1000).toISOString() : new Date().toISOString(), + issued_at: decoded?.iat + ? new Date(decoded.iat * 1000).toISOString() + : new Date().toISOString(), expires_at: decoded?.exp ? new Date(decoded.exp * 1000).toISOString() : null, ip_address: input.ipAddress ?? null, }); @@ -273,12 +302,46 @@ export class AuthService { } } + async cleanupExpiredChallenges(maxAgeMs = 24 * 60 * 60 * 1000): Promise { + const before = new Date(Date.now() - maxAgeMs); + try { + const deleted = await this.challengeRepository.deleteExpired(before); + this.logger?.info("Cleaned up expired challenges", { count: deleted }); + return deleted; + } catch (error) { + this.logger?.error("Failed to cleanup expired challenges", { error }); + throw new HttpError(500, "Failed to cleanup expired challenges."); + } + } + + async getChallengeMetrics(): Promise<{ + active: number; + consumed: number; + expired: number; + }> { + try { + const [active, consumed, expired] = await Promise.all([ + this.challengeRepository.countByStatus("active"), + this.challengeRepository.countByStatus("consumed"), + this.challengeRepository.countByStatus("expired"), + ]); + return { active, consumed, expired }; + } catch (error) { + this.logger?.error("Failed to get challenge metrics", { error }); + throw new HttpError(500, "Failed to get challenge metrics."); + } + } + async getCurrentUser(token: string): Promise { let payload: AuthTokenPayload; const sanitizedToken = token?.trim(); if (!sanitizedToken) { - throw new HttpError(401, "Invalid or expired token.", buildAuthFailureDetails(token, "missing_token")); + throw new HttpError( + 401, + "Invalid or expired token.", + buildAuthFailureDetails(token, "missing_token") + ); } try { @@ -287,7 +350,7 @@ export class AuthService { throw new HttpError( 401, "Invalid or expired token.", - buildAuthFailureDetails(sanitizedToken, classifyJwtError(error)), + buildAuthFailureDetails(sanitizedToken, classifyJwtError(error)) ); } @@ -295,7 +358,7 @@ export class AuthService { throw new HttpError( 401, "Invalid token payload.", - buildAuthFailureDetails(sanitizedToken, "invalid_token"), + buildAuthFailureDetails(sanitizedToken, "invalid_token") ); } @@ -321,8 +384,8 @@ export class AuthService { } private async upsertUser(publicKey: string): Promise { + const sanitized = publicKey.trim(); try { - const sanitized = publicKey.trim(); const existingUser = await this.userRepository.findByStellarAddress(sanitized); if (existingUser) { @@ -333,6 +396,10 @@ export class AuthService { stellarAddress: sanitized, }); } catch (error) { + if (error instanceof Error && error.message.includes("duplicate key")) { + const existing = await this.userRepository.findByStellarAddress(sanitized); + if (existing) return existing; + } this.logger?.error("upsertUser failed", { error, publicKey }); throw error; } @@ -352,13 +419,13 @@ export class AuthService { { ...signOptions, subject: user.stellarAddress, - }, + } ); } } class TypeOrmUserRepository implements UserRepositoryContract { - constructor(private readonly repository: Repository) { } + constructor(private readonly repository: Repository) {} findById(id: string): Promise { return this.repository.findOne({ @@ -372,6 +439,42 @@ class TypeOrmUserRepository implements UserRepositoryContract { }); } + findByEmail(email: string): Promise { + return this.repository.findOne({ + where: { email }, + }); + } + + findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }): Promise { + const qb = this.repository.createQueryBuilder("user"); + qb.where("user.deletedAt IS NULL"); + + if (options?.cursor) { + const direction = options.order === "ASC" ? ">" : "<"; + qb.andWhere(`user.id ${direction} :cursor`, { cursor: options.cursor }); + } + + qb.orderBy("user.id", options?.order ?? "DESC"); + if (options?.take) qb.take(options.take); + if (options?.skip) qb.skip(options.skip); + + return qb.getMany(); + } + + async count(options?: { cursor?: string }): Promise { + const qb = this.repository.createQueryBuilder("user"); + qb.where("user.deletedAt IS NULL"); + if (options?.cursor) { + qb.andWhere("user.id < :cursor", { cursor: options.cursor }); + } + return qb.getCount(); + } + async save(user: Partial): Promise { const entity = this.repository.create(user); return this.repository.save(entity); @@ -379,7 +482,7 @@ class TypeOrmUserRepository implements UserRepositoryContract { } class TypeOrmChallengeRepository implements ChallengeRepositoryContract { - constructor(private readonly repository: Repository) { } + constructor(private readonly repository: Repository) {} async create(input: CreateChallengeRecordInput): Promise { const entity = this.repository.create({ @@ -397,7 +500,7 @@ class TypeOrmChallengeRepository implements ChallengeRepositoryContract { findByAddressAndNonceHash( stellarAddress: string, - nonceHash: string, + nonceHash: string ): Promise { return this.repository.findOne({ where: { @@ -415,25 +518,52 @@ class TypeOrmChallengeRepository implements ChallengeRepositoryContract { }, { consumedAt, - }, + } ); return (result.affected ?? 0) > 0; } + + async deleteExpired(before: Date): Promise { + const result = await this.repository + .createQueryBuilder() + .delete() + .where("expiresAt < :before", { before }) + .orWhere("consumedAt IS NOT NULL AND consumedAt < :before", { before }) + .execute(); + return result.affected ?? 0; + } + + async countByStatus(status: "active" | "consumed" | "expired"): Promise { + const qb = this.repository.createQueryBuilder("challenge"); + const now = new Date(); + switch (status) { + case "active": + qb.where("challenge.consumedAt IS NULL AND challenge.expiresAt > :now", { now }); + break; + case "consumed": + qb.where("challenge.consumedAt IS NOT NULL"); + break; + case "expired": + qb.where("challenge.consumedAt IS NULL AND challenge.expiresAt <= :now", { now }); + break; + } + return qb.getCount(); + } } export function createAuthService( dataSource: DataSource, config: Pick, logger?: AppLogger, + metrics?: MetricsRegistry ): AuthService { return new AuthService({ userRepository: new TypeOrmUserRepository(dataSource.getRepository(User)), - challengeRepository: new TypeOrmChallengeRepository( - dataSource.getRepository(AuthChallenge), - ), + challengeRepository: new TypeOrmChallengeRepository(dataSource.getRepository(AuthChallenge)), config, logger, + metrics, }); } @@ -473,10 +603,7 @@ function decodeSignature(signature: string): Buffer { ? trimmedSignature.slice(2) : trimmedSignature; - if ( - /^[a-fA-F0-9]+$/.test(normalizedHexSignature) && - normalizedHexSignature.length % 2 === 0 - ) { + if (/^[a-fA-F0-9]+$/.test(normalizedHexSignature) && normalizedHexSignature.length % 2 === 0) { return Buffer.from(normalizedHexSignature, "hex"); } @@ -484,9 +611,7 @@ function decodeSignature(signature: string): Buffer { throw new HttpError(400, "Signature must be base64, base64url, or hex encoded."); } - const normalizedBase64Signature = trimmedSignature - .replace(/-/g, "+") - .replace(/_/g, "/"); + const normalizedBase64Signature = trimmedSignature.replace(/-/g, "+").replace(/_/g, "/"); const paddingLength = normalizedBase64Signature.length % 4; const paddedBase64Signature = paddingLength === 0 diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts index 34a2434..6d909b9 100644 --- a/src/services/investment.service.ts +++ b/src/services/investment.service.ts @@ -84,35 +84,35 @@ export class InvestmentService { let failedCount = 0; for (const investment of investments) { - const amount = new Decimal(investment.investmentAmount); - totalInvested = totalInvested.plus(amount); + const amount = new Decimal(investment.investmentAmount); + totalInvested = totalInvested.plus(amount); if (ACTIVE_INVESTMENT_STATUSES.includes(investment.status)) { - activeCount += 1; - activeTotal = activeTotal.plus(amount); - } - - if (SETTLED_INVESTMENT_STATUSES.includes(investment.status)) { - settledCount += 1; - if (investment.actualReturn !== null) { - totalReturns = totalReturns.plus(new Decimal(investment.actualReturn)); - } - } - - if (FAILED_INVESTMENT_STATUSES.includes(investment.status)) { - failedCount += 1; - } + activeCount += 1; + activeTotal = activeTotal.plus(amount); + } + + if (SETTLED_INVESTMENT_STATUSES.includes(investment.status)) { + settledCount += 1; + if (investment.actualReturn !== null) { + totalReturns = totalReturns.plus(new Decimal(investment.actualReturn)); + } + } + + if (FAILED_INVESTMENT_STATUSES.includes(investment.status)) { + failedCount += 1; + } } return { - totalInvested: totalInvested.toFixed(4), - totalReturns: totalReturns.toFixed(4), - activeInvestments: activeCount, - activeCount, - activeTotal: activeTotal.toFixed(4), - settledCount, - settledReturns: totalReturns.toFixed(4), - failedCount, + totalInvested: totalInvested.toFixed(4), + totalReturns: totalReturns.toFixed(4), + activeInvestments: activeCount, + activeCount, + activeTotal: activeTotal.toFixed(4), + settledCount, + settledReturns: totalReturns.toFixed(4), + failedCount, }; } @@ -193,9 +193,10 @@ export class InvestmentService { // Settled returns if (SETTLED_INVESTMENT_STATUSES.includes(investment.status)) { - const actualReturn = investment.actualReturn !== null && investment.actualReturn !== undefined - ? new Decimal(investment.actualReturn) - : expectedReturn; + const actualReturn = + investment.actualReturn !== null && investment.actualReturn !== undefined + ? new Decimal(investment.actualReturn) + : expectedReturn; const profit = actualReturn.minus(amount); if (profit.gt(0)) { totalProfitEarned = totalProfitEarned.plus(profit); @@ -222,9 +223,10 @@ export class InvestmentService { monthEntry.invested = monthEntry.invested.plus(amount); if (investment.status === InvestmentStatus.SETTLED) { - const actualReturn = investment.actualReturn !== null && investment.actualReturn !== undefined - ? new Decimal(investment.actualReturn) - : expectedReturn; + const actualReturn = + investment.actualReturn !== null && investment.actualReturn !== undefined + ? new Decimal(investment.actualReturn) + : expectedReturn; monthEntry.returned = monthEntry.returned.plus(actualReturn); const profit = actualReturn.minus(amount); if (profit.gt(0)) { @@ -246,9 +248,7 @@ export class InvestmentService { ? weightedYieldSum.dividedBy(totalDeployedCapital).toFixed(2) : "0.00"; - const projectedTotalReturn = pendingPayouts.gt(0) - ? pendingPayouts - : totalDeployedCapital; + const projectedTotalReturn = pendingPayouts.gt(0) ? pendingPayouts : totalDeployedCapital; const monthlyPerformance: MonthlyYieldMetric[] = Array.from(monthlyMap.entries()).map( ([month, data]) => ({ @@ -258,7 +258,7 @@ export class InvestmentService { profit: data.profit.toFixed(4), averageYieldPercent: data.count > 0 ? data.yieldSum.dividedBy(data.count).toFixed(2) : "0.00", - }), + }) ); return { @@ -310,7 +310,7 @@ export class InvestmentService { if (invoice.status !== InvoiceStatus.PUBLISHED) { throw new ServiceError( "INVALID_INVOICE_STATUS", - `Cannot invest in an invoice with status ${invoice.status}`, + `Cannot invest in an invoice with status ${invoice.status}` ); } @@ -319,7 +319,7 @@ export class InvestmentService { throw new ServiceError( "invoice_expired", "Invoice has passed its due date and is no longer accepting investments", - 422, + 422 ); } @@ -339,7 +339,7 @@ export class InvestmentService { const totalInvested = activeInvestments.reduce( (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), - new Decimal(0), + new Decimal(0) ); const netAmount = new Decimal(invoice.netAmount); @@ -348,7 +348,7 @@ export class InvestmentService { if (amount.gt(remainingCapacity)) { throw new ServiceError( "INSUFFICIENT_CAPACITY", - `Investment amount ${amount.toString()} exceeds remaining capacity ${remainingCapacity.toString()}`, + `Investment amount ${amount.toString()} exceeds remaining capacity ${remainingCapacity.toString()}` ); } diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 47c8e80..625e6bf 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -42,7 +42,7 @@ export interface NotificationSink { userId: string, type: NotificationType, title: string, - message: string, + message: string ): Promise; } @@ -159,7 +159,11 @@ export interface GetInvoicesOptions { */ const VALID_TRANSITIONS: Record = { [InvoiceStatus.DRAFT]: [InvoiceStatus.PENDING, InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED], - [InvoiceStatus.PENDING]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED], + [InvoiceStatus.PENDING]: [ + InvoiceStatus.PUBLISHED, + InvoiceStatus.CANCELLED, + InvoiceStatus.REJECTED, + ], [InvoiceStatus.PUBLISHED]: [InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED], [InvoiceStatus.FUNDED]: [InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED], [InvoiceStatus.SETTLED]: [], @@ -196,7 +200,13 @@ export class InvoiceService { try { const amt = new Decimal(amount); const disc = new Decimal(discountRate); - if (!amt.isFinite() || !disc.isFinite() || amt.isNegative() || disc.isNegative() || disc.gt(100)) { + if ( + !amt.isFinite() || + !disc.isFinite() || + amt.isNegative() || + disc.isNegative() || + disc.gt(100) + ) { throw new ServiceError("invalid_amount", "Invalid amount or discount rate", 400); } const netAmount = amt.minus(amt.times(disc.dividedBy(100))); @@ -544,6 +554,17 @@ export class InvoiceService { if (!this.isValidTransition(invoice.status, InvoiceStatus.REJECTED)) { throw new ServiceError( "invalid_status_transition", + `Cannot transition from ${invoice.status} to ${InvoiceStatus.PUBLISHED}`, + 400 + ); + } + + const validationErrors = validateInvoiceForPublish(invoice); + if (validationErrors.length > 0) { + throw new ServiceError( + "invoice_not_publishable", + `Invoice failed pre-publish validation: ${validationErrors.map((e) => e.message).join(" ")}`, + 400 `Cannot transition invoice status from ${invoice.status} to ${InvoiceStatus.REJECTED}`, 409, ); @@ -576,6 +597,67 @@ export class InvoiceService { return this.toDTO(saved); } + /** + * Reject a pending invoice (admin action) + */ + async rejectInvoice(input: { invoiceId: string; rejectionReason: string }): Promise { + const invoice = await this.invoiceRepository.findOne({ + where: { id: input.invoiceId }, + relations: ["seller"], + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + // Check if already rejected + if (invoice.status === InvoiceStatus.REJECTED) { + throw new ServiceError("invoice_already_rejected", "Invoice is already rejected", 409); + } + + // Check if transition is valid + if (!this.isValidTransition(invoice.status, InvoiceStatus.REJECTED)) { + throw new ServiceError( + "invalid_status_transition", + `Cannot transition from ${invoice.status} to ${InvoiceStatus.REJECTED}`, + 409 + ); + } + + const previousStatus = invoice.status; + invoice.status = InvoiceStatus.REJECTED; + invoice.rejectionReason = input.rejectionReason.trim(); + const updated = await this.invoiceRepository.save(invoice); + + const seller = invoice.seller as unknown as User; + logInvoiceTransition(logger, { + invoiceId: updated.id, + fromState: previousStatus, + toState: InvoiceStatus.REJECTED, + actorWallet: seller?.stellarAddress ?? "admin", + reason: "admin_rejected", + }); + + // Notify seller if notification sink is available + if (this.notificationSink && seller) { + try { + await this.notificationSink.createNotification( + seller.id, + NotificationType.INVOICE, + "Invoice Rejected", + `Your invoice ${invoice.invoiceNumber} has been rejected: ${input.rejectionReason}` + ); + } catch (notifyError) { + logger.warn("Failed to notify seller of invoice rejection", { + error: notifyError, + invoiceId: invoice.id, + }); + } + } + + return this.toDTO(updated); + } + /** * Publish several draft invoices in one atomic step. * @@ -590,7 +672,7 @@ export class InvoiceService { * on each retry. */ async publishInvoicesBatch( - input: BatchPublishInvoicesInput, + input: BatchPublishInvoicesInput ): Promise { const { invoiceIds, sellerId } = input; @@ -604,7 +686,7 @@ export class InvoiceService { throw new ServiceError( "batch_publish_unavailable", "Batch publishing requires a database connection", - 503, + 503 ); } @@ -617,6 +699,15 @@ export class InvoiceService { // Batch fetch: single query with In(uniqueIds) avoids N round-trips let fetched: Array<{ invoiceId: string; invoice: Invoice | null }>; try { + fetched = await Promise.all( + uniqueIds.map(async (invoiceId) => ({ + invoiceId, + invoice: await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + relations: ["seller"], + }), + })) + ); const invoices = await this.invoiceRepository.find({ where: { id: In(uniqueIds) }, relations: ["seller"], @@ -628,7 +719,11 @@ export class InvoiceService { })); } catch (error) { logger.error("Failed to fetch batch invoices", { error, sellerId }); - throw new ServiceError("batch_fetch_failed", "Failed to fetch invoices for batch publish", 500); + throw new ServiceError( + "batch_fetch_failed", + "Failed to fetch invoices for batch publish", + 500 + ); } for (const { invoiceId, invoice } of fetched) { @@ -655,7 +750,7 @@ export class InvoiceService { throw new ServiceError( "kyc_approval_required", "KYC approval is required to publish invoices", - 403, + 403 ); } @@ -686,7 +781,7 @@ export class InvoiceService { "batch_publish_rejected", `${rejections.length} of ${uniqueIds.length} invoices cannot be published; no invoices were changed`, 400, - { rejections }, + { rejections } ); } @@ -805,11 +900,7 @@ export class InvoiceService { } if (!this.dataSource) { - throw new ServiceError( - "internal_error", - "Database connection unavailable", - 500 - ); + throw new ServiceError("internal_error", "Database connection unavailable", 500); } const investmentRepository = this.dataSource.getRepository(Investment); diff --git a/src/services/kyc.service.ts b/src/services/kyc.service.ts index c7ee964..014faf7 100644 --- a/src/services/kyc.service.ts +++ b/src/services/kyc.service.ts @@ -24,10 +24,13 @@ export class KycService { constructor( private readonly dataSource: DataSource, private readonly webhookSecret: string, - private readonly appLogger: AppLogger = logger, + private readonly appLogger: AppLogger = logger ) {} - async submitKycVerification(userId: string, providerData: KycProviderData = {}): Promise { + async submitKycVerification( + userId: string, + providerData: KycProviderData = {} + ): Promise { return this.dataSource.transaction(async (manager) => { const user = await manager.getRepository(User).findOneBy({ id: userId }); if (!user) throw new HttpError(404, "User not found."); @@ -37,10 +40,16 @@ export class KycService { userId, verificationType: providerData.verificationType ?? KYCVerificationType.IDENTITY, status: KYCStatus.PENDING, - documents: providerData.documents ?? (providerData.providerReference ? { providerReference: providerData.providerReference } : null), + documents: + providerData.documents ?? + (providerData.providerReference + ? { providerReference: providerData.providerReference } + : null), }); const saved = await repository.save(verification); - await manager.getRepository(User).update(userId, { kycStatus: KYCStatus.PENDING, isKycVerified: false }); + await manager + .getRepository(User) + .update(userId, { kycStatus: KYCStatus.PENDING, isKycVerified: false }); return saved; }); } @@ -51,7 +60,10 @@ export class KycService { const expected = crypto.createHmac("sha256", this.webhookSecret).update(rawBody).digest("hex"); const suppliedBuffer = Buffer.from(supplied, "hex"); const expectedBuffer = Buffer.from(expected, "hex"); - return suppliedBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(suppliedBuffer, expectedBuffer); + return ( + suppliedBuffer.length === expectedBuffer.length && + crypto.timingSafeEqual(suppliedBuffer, expectedBuffer) + ); } async processWebhook(payload: KycWebhookPayload): Promise { diff --git a/src/services/marketplace.service.ts b/src/services/marketplace.service.ts index f81d7d1..1bbd356 100644 --- a/src/services/marketplace.service.ts +++ b/src/services/marketplace.service.ts @@ -59,7 +59,7 @@ export interface MarketplaceCursorPage { export interface MarketplaceRepositoryContract { findPublishedInvoices( filters: MarketplaceFilters, - pagination: PaginationOptions, + pagination: PaginationOptions ): Promise<{ invoices: Invoice[]; total: number }>; /** * Optional so that existing fake repositories implementing this contract @@ -69,7 +69,7 @@ export interface MarketplaceRepositoryContract { */ findPublishedInvoicesByCursor?( filters: MarketplaceFilters, - pagination: CursorPaginationOptions, + pagination: CursorPaginationOptions ): Promise<{ invoices: Invoice[]; nextCursor: string | null; hasMore: boolean }>; } @@ -86,7 +86,7 @@ export class MarketplaceService { async getPublishedInvoices( filters: MarketplaceFilters = {}, - pagination: PaginationOptions = { page: 1, limit: 20 }, + pagination: PaginationOptions = { page: 1, limit: 20 } ): Promise { // Set default filters const normalizedFilters: MarketplaceFilters = { @@ -107,7 +107,7 @@ export class MarketplaceService { const { invoices, total } = await this.marketplaceRepository.findPublishedInvoices( normalizedFilters, - normalizedPagination, + normalizedPagination ); const publicInvoices: PublicInvoice[] = invoices.map(this.toPublicInvoice); @@ -132,7 +132,7 @@ export class MarketplaceService { */ async getPublishedInvoicesByCursor( filters: MarketplaceFilters = {}, - pagination: CursorPaginationOptions, + pagination: CursorPaginationOptions ): Promise { const normalizedFilters: MarketplaceFilters = { status: filters.status || [InvoiceStatus.PUBLISHED], @@ -146,7 +146,7 @@ export class MarketplaceService { if (!this.marketplaceRepository.findPublishedInvoicesByCursor) { throw new Error( - "getPublishedInvoicesByCursor: the configured MarketplaceRepositoryContract does not implement findPublishedInvoicesByCursor", + "getPublishedInvoicesByCursor: the configured MarketplaceRepositoryContract does not implement findPublishedInvoicesByCursor" ); } @@ -189,7 +189,7 @@ class TypeORMMarketplaceRepository implements MarketplaceRepositoryContract { async findPublishedInvoices( filters: MarketplaceFilters, - pagination: PaginationOptions, + pagination: PaginationOptions ): Promise<{ invoices: Invoice[]; total: number }> { const queryBuilder = this.repository .createQueryBuilder("invoice") @@ -278,7 +278,7 @@ class TypeORMMarketplaceRepository implements MarketplaceRepositoryContract { async findPublishedInvoicesByCursor( filters: MarketplaceFilters, - pagination: CursorPaginationOptions, + pagination: CursorPaginationOptions ): Promise<{ invoices: Invoice[]; nextCursor: string | null; hasMore: boolean }> { const queryBuilder = this.repository .createQueryBuilder("invoice") @@ -335,4 +335,4 @@ export function createMarketplaceService(dataSource: DataSource): MarketplaceSer return new MarketplaceService({ marketplaceRepository, }); -} \ No newline at end of file +} diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 1a15630..a2d65d8 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -30,7 +30,7 @@ export interface NotificationRepositoryContract { userId: string, type: NotificationType, title: string, - message: string, + message: string ): Promise; findByIdAndUserId(id: string, userId: string): Promise; markRead(id: string, userId: string): Promise; @@ -38,9 +38,7 @@ export interface NotificationRepositoryContract { } export class NotificationService { - constructor( - private readonly notificationRepository: NotificationRepositoryContract, - ) {} + constructor(private readonly notificationRepository: NotificationRepositoryContract) {} /** * Creates a notification for a user. @@ -53,24 +51,19 @@ export class NotificationService { userId: string, type: NotificationType, title: string, - message: string, + message: string ): Promise { return this.notificationRepository.create(userId, type, title, message); } - async listNotifications( - options: ListNotificationsOptions, - ): Promise { + async listNotifications(options: ListNotificationsOptions): Promise { return this.notificationRepository.list(options); } - async markNotificationRead( - notificationId: string, - userId: string, - ): Promise { + async markNotificationRead(notificationId: string, userId: string): Promise { const notification = await this.notificationRepository.findByIdAndUserId( notificationId, - userId, + userId ); if (!notification) { @@ -92,7 +85,7 @@ class TypeOrmNotificationRepository implements NotificationRepositoryContract { userId: string, type: NotificationType, title: string, - message: string, + message: string ): Promise { const entity = this.repository.create({ userId, type, title, message }); return this.repository.save(entity); @@ -112,15 +105,7 @@ class TypeOrmNotificationRepository implements NotificationRepositoryContract { } async list(options: ListNotificationsOptions): Promise { - const { - userId, - page = 1, - limit = 20, - read, - type, - sortOrder = "desc", - cursor, - } = options; + const { userId, page = 1, limit = 20, read, type, sortOrder = "desc", cursor } = options; const qb = this.repository .createQueryBuilder("n") @@ -131,14 +116,22 @@ class TypeOrmNotificationRepository implements NotificationRepositoryContract { if (cursor) { const decoded = Buffer.from(cursor, "base64").toString("utf8").split("::"); - if (decoded.length !== 2 || !decoded[0] || !decoded[1] || Number.isNaN(Date.parse(decoded[0]))) { + if ( + decoded.length !== 2 || + !decoded[0] || + !decoded[1] || + Number.isNaN(Date.parse(decoded[0])) + ) { throw new HttpError(400, "Invalid notification cursor."); } const operator = sortOrder === "asc" ? ">" : "<"; - qb.andWhere(`(n.timestamp ${operator} :cursorTimestamp OR (n.timestamp = :cursorTimestamp AND n.id ${operator} :cursorId))`, { - cursorTimestamp: new Date(decoded[0]), - cursorId: decoded[1], - }); + qb.andWhere( + `(n.timestamp ${operator} :cursorTimestamp OR (n.timestamp = :cursorTimestamp AND n.id ${operator} :cursorId))`, + { + cursorTimestamp: new Date(decoded[0]), + cursorId: decoded[1], + } + ); } else { qb.skip((page - 1) * limit); } @@ -155,9 +148,10 @@ class TypeOrmNotificationRepository implements NotificationRepositoryContract { const hasMore = rows.length > limit; const data = rows.slice(0, limit); const last = data[data.length - 1]; - const nextCursor = hasMore && last - ? Buffer.from(`${last.timestamp.toISOString()}::${last.id}`).toString("base64") - : null; + const nextCursor = + hasMore && last + ? Buffer.from(`${last.timestamp.toISOString()}::${last.id}`).toString("base64") + : null; return { data, @@ -175,6 +169,6 @@ class TypeOrmNotificationRepository implements NotificationRepositoryContract { export function createNotificationService(dataSource: DataSource): NotificationService { return new NotificationService( - new TypeOrmNotificationRepository(dataSource.getRepository(Notification)), + new TypeOrmNotificationRepository(dataSource.getRepository(Notification)) ); } diff --git a/src/services/settlement.service.ts b/src/services/settlement.service.ts index 49dfa24..933d949 100644 --- a/src/services/settlement.service.ts +++ b/src/services/settlement.service.ts @@ -25,7 +25,10 @@ export interface SettleInvoiceInput { actorWallet: string; } -export interface PaymentDistributorSettlementConfig { feeRecipient: string; feeBps: number; } +export interface PaymentDistributorSettlementConfig { + feeRecipient: string; + feeBps: number; +} export interface InvestorSettlement { investmentId: string; @@ -43,7 +46,11 @@ export interface SettleInvoiceResult { } export class SettlementService { - constructor(private readonly dataSource: DataSource, private readonly paymentDistributor?: PaymentDistributorContractService, private readonly distributorConfig?: PaymentDistributorSettlementConfig) {} + constructor( + private readonly dataSource: DataSource, + private readonly paymentDistributor?: PaymentDistributorContractService, + private readonly distributorConfig?: PaymentDistributorSettlementConfig + ) {} /** * Settles a funded invoice by distributing proceeds to each investor @@ -54,10 +61,7 @@ export class SettlementService { const proceeds = new Decimal(proceedsInput); if (proceeds.isNegative() || proceeds.isZero()) { - throw new ServiceError( - "INVALID_PROCEEDS", - "Settlement proceeds must be greater than zero", - ); + throw new ServiceError("INVALID_PROCEEDS", "Settlement proceeds must be greater than zero"); } return await this.dataSource.transaction(async (transactionalEntityManager: EntityManager) => { @@ -85,7 +89,7 @@ export class SettlementService { if (invoice.status !== InvoiceStatus.FUNDED) { throw new ServiceError( "INVALID_INVOICE_STATUS", - `INVALID_INVOICE_STATUS: Cannot settle an invoice with status ${invoice.status}`, + `INVALID_INVOICE_STATUS: Cannot settle an invoice with status ${invoice.status}` ); } @@ -98,14 +102,14 @@ export class SettlementService { if (investments.length === 0) { throw new ServiceError( "NO_CONFIRMED_INVESTMENTS", - "Invoice has no confirmed investments to settle", + "Invoice has no confirmed investments to settle" ); } // 4. Distribute proceeds pro-rata to each investor's share of the total funded amount const totalFunded = investments.reduce( (sum, investment) => sum.plus(new Decimal(investment.investmentAmount)), - new Decimal(0), + new Decimal(0) ); const totalFundedScaled = decimalStringToScaledBigInt(totalFunded.toFixed(4)); const proceedsScaled = decimalStringToScaledBigInt(proceeds.toFixed(4)); @@ -118,7 +122,10 @@ export class SettlementService { if (this.paymentDistributor) { if (!this.distributorConfig) { - throw new ServiceError("DISTRIBUTOR_CONFIGURATION_MISSING", "Payment distributor fee configuration is required"); + throw new ServiceError( + "DISTRIBUTOR_CONFIGURATION_MISSING", + "Payment distributor fee configuration is required" + ); } const distribution = await this.paymentDistributor.distributePayouts({ invoiceId: invoice.id, @@ -127,20 +134,28 @@ export class SettlementService { feeBps: this.distributorConfig.feeBps, recipients: investments.map((investment) => ({ address: investment.investor?.stellarAddress ?? investment.investorId, - amountStroops: computeInvestorReturn(decimalStringToScaledBigInt(investment.investmentAmount), totalFundedScaled, distributableScaled) * DECIMAL_SCALE_TO_STROOP_FACTOR, + amountStroops: + computeInvestorReturn( + decimalStringToScaledBigInt(investment.investmentAmount), + totalFundedScaled, + distributableScaled + ) * DECIMAL_SCALE_TO_STROOP_FACTOR, })), }); distributionTransactionHash = distribution.transactionHash; - await transactionalEntityManager.save(Transaction, transactionalEntityManager.create(Transaction, { - userId: invoice.sellerId, - invoiceId: invoice.id, - investmentId: null, - type: TransactionType.PAYMENT, - amount: proceeds.toFixed(4), - stellarTxHash: distribution.transactionHash, - stellarOperationIndex: 0, - status: TransactionStatus.COMPLETED, - })); + await transactionalEntityManager.save( + Transaction, + transactionalEntityManager.create(Transaction, { + userId: invoice.sellerId, + invoiceId: invoice.id, + investmentId: null, + type: TransactionType.PAYMENT, + amount: proceeds.toFixed(4), + stellarTxHash: distribution.transactionHash, + stellarOperationIndex: 0, + status: TransactionStatus.COMPLETED, + }) + ); } for (const investment of investments) { @@ -148,7 +163,7 @@ export class SettlementService { const actualReturnScaled = computeInvestorReturn( investmentAmountScaled, totalFundedScaled, - distributableScaled, + distributableScaled ); investment.actualReturn = scaledBigIntToDecimalString(actualReturnScaled); @@ -193,6 +208,10 @@ export class SettlementService { } } -export function createSettlementService(dataSource: DataSource, paymentDistributor?: PaymentDistributorContractService, distributorConfig?: PaymentDistributorSettlementConfig): SettlementService { +export function createSettlementService( + dataSource: DataSource, + paymentDistributor?: PaymentDistributorContractService, + distributorConfig?: PaymentDistributorSettlementConfig +): SettlementService { return new SettlementService(dataSource, paymentDistributor, distributorConfig); } diff --git a/src/services/stellar/contract-guard.service.ts b/src/services/stellar/contract-guard.service.ts index bd1e7e2..74618e5 100644 --- a/src/services/stellar/contract-guard.service.ts +++ b/src/services/stellar/contract-guard.service.ts @@ -191,7 +191,7 @@ export function buildPausedLedgerKey(contractId: string): string { contract: contract.address().toScAddress(), key: xdr.ScVal.scvSymbol(PAUSED_STORAGE_KEY), durability: xdr.ContractDataDurability.persistent(), - }), + }) ); return key.toXDR("base64"); } @@ -210,7 +210,7 @@ export function decodePausedEntry(entryXdr: string): boolean { } export function createContractGuardService( - dependencies: ContractGuardServiceDependencies, + dependencies: ContractGuardServiceDependencies ): ContractGuardService { return new ContractGuardService(dependencies); } diff --git a/src/services/stellar/event-indexer.service.ts b/src/services/stellar/event-indexer.service.ts index 5a50fc3..f301a24 100644 --- a/src/services/stellar/event-indexer.service.ts +++ b/src/services/stellar/event-indexer.service.ts @@ -114,17 +114,18 @@ export class EventIndexerService { const contractIdStr = rawEvent.contractId ? typeof rawRecord.contractId === "string" ? (rawRecord.contractId as string) - : typeof (rawEvent.contractId as unknown as { contractId?: () => string }).contractId === "function" - ? (rawEvent.contractId as unknown as { contractId: () => string }).contractId() - : String(rawEvent.contractId) + : typeof (rawEvent.contractId as unknown as { contractId?: () => string }).contractId === + "function" + ? (rawEvent.contractId as unknown as { contractId: () => string }).contractId() + : String(rawEvent.contractId) : ""; const txHash = typeof rawRecord.txHash === "string" ? rawRecord.txHash : typeof rawRecord.pagingToken === "string" - ? rawRecord.pagingToken - : rawEvent.id; + ? rawRecord.pagingToken + : rawEvent.id; return { id: rawEvent.id, @@ -142,11 +143,8 @@ export class EventIndexerService { /** * Polls contract events from Soroban RPC matching configured contract IDs. */ - public async pollContractEvents( - options: PollEventsOptions = {}, - ): Promise { - const startLedger = - options.startLedger ?? (await this.getLastIndexedLedger()) + 1; + public async pollContractEvents(options: PollEventsOptions = {}): Promise { + const startLedger = options.startLedger ?? (await this.getLastIndexedLedger()) + 1; try { const filters = [ @@ -219,7 +217,7 @@ export class EventIndexerService { if (this.eventLogRepository) { await this.eventLogRepository.update( { txHash: event.txHash, topic: event.topic }, - { processed: true }, + { processed: true } ); } diff --git a/src/services/stellar/invoice-escrow-contract.service.ts b/src/services/stellar/invoice-escrow-contract.service.ts index f6866e2..ad3ef7c 100644 --- a/src/services/stellar/invoice-escrow-contract.service.ts +++ b/src/services/stellar/invoice-escrow-contract.service.ts @@ -21,12 +21,7 @@ import type { } from "../../types/soroban.types"; export type CreateEscrowInput = CreateEscrowParams; -export type { - CreateEscrowResult, - FundEscrowParams, - RecordPaymentParams, - SettleEscrowParams, -}; +export type { CreateEscrowResult, FundEscrowParams, RecordPaymentParams, SettleEscrowParams }; export interface InvoiceEscrowContractServiceDependencies { contractId: string; @@ -51,7 +46,7 @@ export class InvoiceEscrowContractService { constructor( dependenciesOrContractId: string | InvoiceEscrowContractServiceDependencies, - logger?: AppLogger, + logger?: AppLogger ) { if (typeof dependenciesOrContractId === "string") { if (!dependenciesOrContractId || !dependenciesOrContractId.trim()) { @@ -106,8 +101,9 @@ export class InvoiceEscrowContractService { sellerAddress: string, amountStroops: bigint | number | string, dueDateTimestamp: number, - paymentTokenAddress: string, + paymentTokenAddress: string ): xdr.Operation { + const amountBigInt = typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { throw new Error("invoiceId is required."); } @@ -129,6 +125,7 @@ export class InvoiceEscrowContractService { new Address(sellerAddress.trim()).toScVal(), nativeToScVal(amountBigInt, { type: "i128" }), nativeToScVal(dueDateTimestamp, { type: "u64" }), + new Address(paymentTokenAddress).toScVal() new Address(paymentTokenAddress.trim()).toScVal(), ); } @@ -139,8 +136,15 @@ export class InvoiceEscrowContractService { public buildFundEscrowTx( invoiceId: string, investorAddress: string, - amountStroops: bigint | number | string, + amountStroops: bigint | number | string ): xdr.Operation { + const amountBigInt = typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + + return this.contract.call( + "fund_escrow", + nativeToScVal(invoiceId, { type: "symbol" }), + new Address(investorAddress).toScVal(), + nativeToScVal(amountBigInt, { type: "i128" }) if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { throw new Error("invoiceId is required."); } @@ -164,8 +168,15 @@ export class InvoiceEscrowContractService { public buildRecordPaymentTx( invoiceId: string, payerAddress: string, - amountStroops: bigint | number | string, + amountStroops: bigint | number | string ): xdr.Operation { + const amountBigInt = typeof amountStroops === "bigint" ? amountStroops : BigInt(amountStroops); + + return this.contract.call( + "record_payment", + nativeToScVal(invoiceId, { type: "symbol" }), + new Address(payerAddress).toScVal(), + nativeToScVal(amountBigInt, { type: "i128" }) if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { throw new Error("invoiceId is required."); } @@ -187,6 +198,7 @@ export class InvoiceEscrowContractService { * Build the Soroban contract invocation operation for settling an escrow. */ public buildSettleEscrowTx(invoiceId: string): xdr.Operation { + return this.contract.call("settle_escrow", nativeToScVal(invoiceId, { type: "symbol" })); if (!invoiceId || typeof invoiceId !== "string" || !invoiceId.trim()) { throw new Error("invoiceId is required."); } @@ -201,7 +213,7 @@ export class InvoiceEscrowContractService { * Simulates a transaction against the Soroban RPC endpoint to verify resource limits and auth footprint. */ public async simulateTransaction( - transaction: Transaction | FeeBumpTransaction, + transaction: Transaction | FeeBumpTransaction ): Promise { if (!this.rpcServer) { throw new Error("Soroban RPC server is not configured for simulation."); @@ -249,7 +261,7 @@ export class InvoiceEscrowContractService { * Submits a transaction to the Stellar network via Soroban RPC sendTransaction. */ public async submitTransaction( - transaction: Transaction | FeeBumpTransaction, + transaction: Transaction | FeeBumpTransaction ): Promise { if (!this.rpcServer) { throw new Error("Soroban RPC server is not configured for submission."); @@ -339,6 +351,9 @@ export class InvoiceEscrowContractService { * Ensures that only sanitized metadata (invoiceId, sorobanContractId, sellerAddress, amountStroops) * is logged without leaking any secret keys, signing seeds, or auth tokens. */ + public async createEscrowOnChain(input: CreateEscrowInput): Promise { + const amountBigInt = + typeof input.amountStroops === "bigint" ? input.amountStroops : BigInt(input.amountStroops); public async createEscrowOnChain( input: CreateEscrowInput, ): Promise { @@ -349,7 +364,7 @@ export class InvoiceEscrowContractService { input.sellerAddress, amountBigInt, input.dueDateTimestamp, - input.paymentTokenAddress, + input.paymentTokenAddress ); const amountStroopsStr = amountBigInt.toString(); diff --git a/src/services/stellar/invoice-token-contract.service.ts b/src/services/stellar/invoice-token-contract.service.ts index d29769a..3305e48 100644 --- a/src/services/stellar/invoice-token-contract.service.ts +++ b/src/services/stellar/invoice-token-contract.service.ts @@ -38,7 +38,7 @@ export class InvoiceTokenContractService { constructor( dependenciesOrContractId: string | InvoiceTokenContractServiceDependencies, - logger?: AppLogger, + logger?: AppLogger ) { if (typeof dependenciesOrContractId === "string") { if (!dependenciesOrContractId) { @@ -75,11 +75,10 @@ export class InvoiceTokenContractService { */ public buildMintTx( recipientAddress: string, - tokenAmount: bigint | number | string, + tokenAmount: bigint | number | string ): xdr.Operation { const toScVal = new Address(recipientAddress).toScVal(); - const amountBigInt = - typeof tokenAmount === "bigint" ? tokenAmount : BigInt(tokenAmount); + const amountBigInt = typeof tokenAmount === "bigint" ? tokenAmount : BigInt(tokenAmount); const amountScVal = nativeToScVal(amountBigInt, { type: "i128" }); return this.contract.call("mint", toScVal, amountScVal); @@ -128,10 +127,9 @@ export class InvoiceTokenContractService { public async mintInvoiceTokens( invoiceId: string, recipientAddress: string, - tokenAmount: bigint | number | string, + tokenAmount: bigint | number | string ): Promise { - const amountBigInt = - typeof tokenAmount === "bigint" ? tokenAmount : BigInt(tokenAmount); + const amountBigInt = typeof tokenAmount === "bigint" ? tokenAmount : BigInt(tokenAmount); const operation = this.buildMintTx(recipientAddress, amountBigInt); this.logger.info("Minted invoice tokens for invoice", { diff --git a/src/services/stellar/orchestrate-investment-funding.service.ts b/src/services/stellar/orchestrate-investment-funding.service.ts index f0aa087..445528e 100644 --- a/src/services/stellar/orchestrate-investment-funding.service.ts +++ b/src/services/stellar/orchestrate-investment-funding.service.ts @@ -23,9 +23,7 @@ export interface SorobanEscrowFundingDraft { } export interface SorobanEscrowClient { - prepareInvestmentFunding( - input: SorobanEscrowFundingInput, - ): Promise; + prepareInvestmentFunding(input: SorobanEscrowFundingInput): Promise; } interface FundingUnitOfWork { @@ -78,17 +76,13 @@ const NOOP_LOGGER: AppLogger = { export class OrchestrateInvestmentFundingService { private readonly logger: AppLogger; - constructor( - private readonly dependencies: OrchestrateInvestmentFundingServiceDependencies, - ) { + constructor(private readonly dependencies: OrchestrateInvestmentFundingServiceDependencies) { this.logger = (dependencies.logger ?? NOOP_LOGGER).child({ component: "soroban-escrow-funding", }); } - async orchestrateFunding( - investmentId: string, - ): Promise { + async orchestrateFunding(investmentId: string): Promise { const investment = await this.dependencies.investmentReader.findById(investmentId); if (!investment) { @@ -99,7 +93,7 @@ export class OrchestrateInvestmentFundingService { throw new ServiceError( "invalid_investment_state", "Only pending investments can be funded.", - 409, + 409 ); } @@ -107,7 +101,7 @@ export class OrchestrateInvestmentFundingService { throw new ServiceError( "invoice_not_found", "Investment must be linked to an invoice before funding.", - 409, + 409 ); } @@ -126,7 +120,7 @@ export class OrchestrateInvestmentFundingService { throw new ServiceError( "soroban_contract_not_configured", "Soroban escrow is enabled but no contract ID is configured.", - 500, + 500 ); } @@ -175,7 +169,7 @@ export class OrchestrateInvestmentFundingService { } const existingTransaction = await unitOfWork.findTransactionByInvestmentIdForUpdate( - lockedInvestment.id, + lockedInvestment.id ); const transaction = @@ -247,7 +241,7 @@ class TypeOrmFundingTransactionRunner implements FundingTransactionRunner { manager.getRepository(Transaction).save(transaction), createTransaction: (input: Partial) => manager.getRepository(Transaction).create(input), - }), + }) ); } } @@ -256,12 +250,10 @@ export function createOrchestrateInvestmentFundingService( dataSource: DataSource, sorobanEscrowClient: SorobanEscrowClient, config: OrchestrateInvestmentFundingServiceDependencies["config"], - logger?: AppLogger, + logger?: AppLogger ): OrchestrateInvestmentFundingService { return new OrchestrateInvestmentFundingService({ - investmentReader: new TypeOrmInvestmentFundingReader( - dataSource.getRepository(Investment), - ), + investmentReader: new TypeOrmInvestmentFundingReader(dataSource.getRepository(Investment)), transactionRunner: new TypeOrmFundingTransactionRunner(dataSource), sorobanEscrowClient, config, diff --git a/src/services/stellar/payment-distributor-contract.service.ts b/src/services/stellar/payment-distributor-contract.service.ts index 0da9875..107fb87 100644 --- a/src/services/stellar/payment-distributor-contract.service.ts +++ b/src/services/stellar/payment-distributor-contract.service.ts @@ -1,4 +1,13 @@ -import { Contract, Address, nativeToScVal, xdr, SorobanRpc, Keypair, TransactionBuilder, BASE_FEE } from "stellar-sdk"; +import { + Contract, + Address, + nativeToScVal, + xdr, + SorobanRpc, + Keypair, + TransactionBuilder, + BASE_FEE, +} from "stellar-sdk"; import type { AppLogger } from "../../observability/logger"; import { logger as globalLogger } from "../../observability/logger"; @@ -31,7 +40,10 @@ export interface DistributePayoutsInput { feeBps: number; } -export interface DistributePayoutsResult { transactionHash: string; ledger: number | null; } +export interface DistributePayoutsResult { + transactionHash: string; + ledger: number | null; +} const MAX_FEE_BPS = 10_000; @@ -59,7 +71,7 @@ export class PaymentDistributorContractService { constructor( dependenciesOrContractId: string | PaymentDistributorContractServiceDependencies, - logger?: AppLogger, + logger?: AppLogger ) { if (typeof dependenciesOrContractId === "string") { if (!dependenciesOrContractId) { @@ -108,7 +120,7 @@ export class PaymentDistributorContractService { invoiceId: string, recipients: PayoutRecipient[], platformFeeAccount: string, - feeBps: number, + feeBps: number ): xdr.Operation { if (recipients.length === 0) { throw new Error("At least one payout recipient is required."); @@ -118,7 +130,7 @@ export class PaymentDistributorContractService { } const recipientAddressesScVal = xdr.ScVal.scvVec( - recipients.map((recipient) => new Address(recipient.address).toScVal()), + recipients.map((recipient) => new Address(recipient.address).toScVal()) ); const recipientAmountsScVal = xdr.ScVal.scvVec( @@ -128,7 +140,7 @@ export class PaymentDistributorContractService { ? recipient.amountStroops : BigInt(recipient.amountStroops); return nativeToScVal(amountBigInt, { type: "i128" }); - }), + }) ); return this.contract.call( @@ -137,7 +149,7 @@ export class PaymentDistributorContractService { recipientAddressesScVal, recipientAmountsScVal, new Address(platformFeeAccount).toScVal(), - nativeToScVal(feeBps, { type: "u32" }), + nativeToScVal(feeBps, { type: "u32" }) ); } @@ -148,7 +160,10 @@ export class PaymentDistributorContractService { if (this.verifyDistributorWiring && !(await this.verifyDistributorWiring())) { throw new Error("Payment distributor is not initialized on the invoice escrow contract."); } - const recipientTotal = input.recipients.reduce((sum, recipient) => sum + BigInt(recipient.amountStroops), 0n); + const recipientTotal = input.recipients.reduce( + (sum, recipient) => sum + BigInt(recipient.amountStroops), + 0n + ); const fee = (input.totalAmountStroops * BigInt(input.feeBps)) / 10_000n; if (recipientTotal + fee > input.totalAmountStroops) { throw new Error("Payout recipients and protocol fee exceed the settlement total."); @@ -159,19 +174,37 @@ export class PaymentDistributorContractService { const transaction = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: this.networkPassphrase, - }).addOperation(this.buildDistributePayoutsTx(input.invoiceId, input.recipients, input.feeRecipient, input.feeBps)).setTimeout(30).build(); + }) + .addOperation( + this.buildDistributePayoutsTx( + input.invoiceId, + input.recipients, + input.feeRecipient, + input.feeBps + ) + ) + .setTimeout(30) + .build(); const prepared = await this.rpcServer.prepareTransaction(transaction); prepared.sign(signer); const submitted = await this.rpcServer.sendTransaction(prepared); - if (submitted.status === "ERROR") throw new Error("Payment distribution transaction was rejected by Soroban RPC."); + if (submitted.status === "ERROR") + throw new Error("Payment distribution transaction was rejected by Soroban RPC."); for (let attempt = 0; attempt < this.confirmationAttempts; attempt++) { const result = await this.rpcServer.getTransaction(submitted.hash); if (result.status === "SUCCESS") { - this.logger.info("Payment distribution confirmed on-chain.", { invoice_id: input.invoiceId, transaction_hash: submitted.hash }); - return { transactionHash: submitted.hash, ledger: "ledger" in result ? Number(result.ledger) : null }; + this.logger.info("Payment distribution confirmed on-chain.", { + invoice_id: input.invoiceId, + transaction_hash: submitted.hash, + }); + return { + transactionHash: submitted.hash, + ledger: "ledger" in result ? Number(result.ledger) : null, + }; } - if (result.status === "FAILED") throw new Error("Payment distribution transaction reverted on-chain."); + if (result.status === "FAILED") + throw new Error("Payment distribution transaction reverted on-chain."); await new Promise((resolve) => setTimeout(resolve, this.confirmationPollMs)); } throw new Error("Timed out waiting for payment distribution confirmation."); diff --git a/src/services/stellar/reconciliation-retry.ts b/src/services/stellar/reconciliation-retry.ts index 7065c44..6a40eb5 100644 --- a/src/services/stellar/reconciliation-retry.ts +++ b/src/services/stellar/reconciliation-retry.ts @@ -10,9 +10,7 @@ import { RetryableHorizonError } from "./verify-payment.service"; * authorization issues, business-rule violations) are not retryable because no * amount of retrying will change the outcome. */ -export type ReconciliationFailureKind = - | "transient_provider" - | "permanent_validation"; +export type ReconciliationFailureKind = "transient_provider" | "permanent_validation"; export interface ReconciliationRetryDecision { /** `true` when the failure is safe to retry on a later tick. */ @@ -62,7 +60,7 @@ function timeoutDecision(attempt: number, name: string): ReconciliationRetryDeci */ export function classifyReconciliationError( error: unknown, - attempt = 1, + attempt = 1 ): ReconciliationRetryDecision { // 1. Explicit request timeouts / aborts are always transient. if (error instanceof Error && TIMEOUT_ERROR_NAMES.has(error.name)) { diff --git a/src/services/stellar/verify-payment.service.ts b/src/services/stellar/verify-payment.service.ts index 0e611ae..6993097 100644 --- a/src/services/stellar/verify-payment.service.ts +++ b/src/services/stellar/verify-payment.service.ts @@ -65,7 +65,7 @@ interface PaymentVerificationUnitOfWork { interface PaymentTransactionRunner { runInTransaction( - callback: (unitOfWork: PaymentVerificationUnitOfWork) => Promise, + callback: (unitOfWork: PaymentVerificationUnitOfWork) => Promise ): Promise; } @@ -89,12 +89,11 @@ export class VerifyPaymentService { this.transactionRunner = dependencies.transactionRunner; this.config = dependencies.config; this.fetchImplementation = dependencies.fetchImplementation ?? fetch; - this.sleep = dependencies.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + this.sleep = + dependencies.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); } - async verifyPayment( - input: PaymentVerificationInput, - ): Promise { + async verifyPayment(input: PaymentVerificationInput): Promise { const investment = await this.investmentReader.findById(input.investmentId); if (!investment) { @@ -104,7 +103,8 @@ export class VerifyPaymentService { if (investment.status === InvestmentStatus.CONFIRMED) { if ( investment.transactionHash === input.stellarTxHash && - investment.stellarOperationIndex === (input.operationIndex ?? investment.stellarOperationIndex) + investment.stellarOperationIndex === + (input.operationIndex ?? investment.stellarOperationIndex) ) { return { outcome: "already_verified", @@ -119,14 +119,14 @@ export class VerifyPaymentService { throw new ServiceError( "reconciliation_conflict", "Investment is already confirmed with a different Stellar payment.", - 409, + 409 ); } const matchedPayment = await this.fetchAndValidatePayment( input.stellarTxHash, investment.investmentAmount, - input.operationIndex, + input.operationIndex ); return this.transactionRunner.runInTransaction(async (unitOfWork) => { @@ -137,14 +137,14 @@ export class VerifyPaymentService { } const linkedTransactions = await unitOfWork.findTransactionsByInvestmentIdForUpdate( - lockedInvestment.id, + lockedInvestment.id ); if (linkedTransactions.length > 1) { throw new ServiceError( "reconciliation_conflict", "Multiple transaction rows are linked to the same investment.", - 409, + 409 ); } @@ -168,7 +168,7 @@ export class VerifyPaymentService { throw new ServiceError( "reconciliation_conflict", "Investment was confirmed by another transaction while verification was in progress.", - 409, + 409 ); } @@ -182,7 +182,7 @@ export class VerifyPaymentService { throw new ServiceError( "reconciliation_conflict", "Transaction row is already linked to a different Stellar hash.", - 409, + 409 ); } @@ -230,22 +230,18 @@ export class VerifyPaymentService { private async fetchAndValidatePayment( stellarTxHash: string, expectedAmount: string, - operationIndex?: number, + operationIndex?: number ): Promise { - const transaction = normalizeHorizonTransaction(await this.fetchJson( - `/transactions/${stellarTxHash}`, - )); + const transaction = normalizeHorizonTransaction( + await this.fetchJson(`/transactions/${stellarTxHash}`) + ); if (!transaction.successful) { - throw new ServiceError( - "invalid_payment", - "The Stellar transaction was not successful.", - 422, - ); + throw new ServiceError("invalid_payment", "The Stellar transaction was not successful.", 422); } const operations = await this.fetchJson( - `/transactions/${stellarTxHash}/operations?limit=200&order=asc`, + `/transactions/${stellarTxHash}/operations?limit=200&order=asc` ); const paymentOperations = (operations._embedded?.records ?? []) @@ -265,11 +261,7 @@ export class VerifyPaymentService { operation.assetIssuer === this.config.usdcAssetIssuer && operation.destination === this.config.escrowPublicKey && operation.amount !== null && - amountsWithinDelta( - operation.amount, - expectedAmount, - this.config.allowedAmountDelta, - ) + amountsWithinDelta(operation.amount, expectedAmount, this.config.allowedAmountDelta) ); }); @@ -277,7 +269,7 @@ export class VerifyPaymentService { throw new ServiceError( "invalid_payment", "No Stellar payment operation matched the expected asset, amount, and destination.", - 422, + 422 ); } @@ -285,7 +277,7 @@ export class VerifyPaymentService { throw new ServiceError( "invalid_payment", "Multiple payment operations matched. Supply operationIndex to disambiguate.", - 422, + 422 ); } @@ -316,7 +308,7 @@ export class VerifyPaymentService { throw new ServiceError( "transaction_not_found", "The Stellar transaction could not be found in Horizon.", - 404, + 404 ); } @@ -328,7 +320,7 @@ export class VerifyPaymentService { throw new ServiceError( "horizon_request_failed", "Horizon rejected the verification request.", - 502, + 502 ); } @@ -342,7 +334,7 @@ export class VerifyPaymentService { throw new ServiceError( "horizon_unavailable", "Horizon is temporarily unavailable. Please retry later.", - 503, + 503 ); } @@ -353,7 +345,7 @@ export class VerifyPaymentService { throw new ServiceError( "horizon_unavailable", "Horizon is temporarily unavailable. Please retry later.", - 503, + 503 ); } } @@ -372,7 +364,7 @@ class TypeOrmTransactionRunner implements PaymentTransactionRunner { constructor(private readonly dataSource: DataSource) {} runInTransaction( - callback: (unitOfWork: PaymentVerificationUnitOfWork) => Promise, + callback: (unitOfWork: PaymentVerificationUnitOfWork) => Promise ): Promise { return this.dataSource.transaction(async (manager) => callback({ @@ -390,14 +382,14 @@ class TypeOrmTransactionRunner implements PaymentTransactionRunner { manager.getRepository(Transaction).save(transaction), createTransaction: (input: Partial) => manager.getRepository(Transaction).create(input), - }), + }) ); } } export function createVerifyPaymentService( dataSource: DataSource, - config: PaymentVerificationConfig, + config: PaymentVerificationConfig ): VerifyPaymentService { return new VerifyPaymentService({ investmentReader: new TypeOrmInvestmentReader(dataSource.getRepository(Investment)), @@ -418,9 +410,8 @@ function amountsWithinDelta(actual: string, expected: string, delta: string): bo const expectedValue = toScaledBigInt(expected, scale); const deltaValue = toScaledBigInt(delta, scale); - const difference = actualValue >= expectedValue - ? actualValue - expectedValue - : expectedValue - actualValue; + const difference = + actualValue >= expectedValue ? actualValue - expectedValue : expectedValue - actualValue; return difference <= deltaValue; } diff --git a/src/services/webhook-dispatcher.service.ts b/src/services/webhook-dispatcher.service.ts index 98f93b9..7aa7400 100644 --- a/src/services/webhook-dispatcher.service.ts +++ b/src/services/webhook-dispatcher.service.ts @@ -24,25 +24,23 @@ export interface WebhookDispatchResult { export class WebhookDispatcherService { constructor( private readonly dataSource: DataSource, - private readonly appLogger: AppLogger = logger, + private readonly appLogger: AppLogger = logger ) {} async dispatchWebhookEvent( eventType: string, payload: unknown, - eventId?: string, + eventId?: string ): Promise { const subscriptions = await this.dataSource .getRepository(WebhookSubscription) .find({ where: { active: true } }); const body = JSON.stringify({ eventType, payload }); const eligible = subscriptions.filter((subscription) => - subscription.eventTypes.includes(eventType), + subscription.eventTypes.includes(eventType) ); return Promise.all( - eligible.map((subscription) => - this.deliver(subscription, eventType, body, eventId), - ), + eligible.map((subscription) => this.deliver(subscription, eventType, body, eventId)) ); } @@ -50,7 +48,7 @@ export class WebhookDispatcherService { subscription: WebhookSubscription, eventType: string, body: string, - eventId?: string, + eventId?: string ): Promise { // Idempotency: a duplicate event (same subscription + eventId) that has // already been delivered successfully must not be re-delivered, otherwise @@ -97,7 +95,7 @@ export class WebhookDispatcherService { attempt, response.status, delivered, - delivered ? null : `HTTP ${response.status}`, + delivered ? null : `HTTP ${response.status}` ); if (delivered) { return { @@ -118,7 +116,7 @@ export class WebhookDispatcherService { attempt, lastStatus, false, - lastError, + lastError ); } if (attempt < 3) { @@ -141,10 +139,7 @@ export class WebhookDispatcherService { }; } - private async alreadyDelivered( - subscriptionId: string, - eventId: string, - ): Promise { + private async alreadyDelivered(subscriptionId: string, eventId: string): Promise { const existing = await this.dataSource .getRepository(WebhookDeliveryLog) .findOne({ where: { subscriptionId, eventId, delivered: true } }); @@ -158,7 +153,7 @@ export class WebhookDispatcherService { attempt: number, responseStatus: number | null, delivered: boolean, - errorMessage: string | null, + errorMessage: string | null ): Promise { await this.dataSource.getRepository(WebhookDeliveryLog).save({ subscriptionId, @@ -182,7 +177,7 @@ export class WebhookDispatcherService { export function createWebhookDispatcherService( dataSource: DataSource, - appLogger?: AppLogger, + appLogger?: AppLogger ): WebhookDispatcherService { return new WebhookDispatcherService(dataSource, appLogger); } diff --git a/src/types/enums.ts b/src/types/enums.ts index 7b403b0..a1d707b 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -57,4 +57,5 @@ export enum NotificationType { PAYMENT = "payment", KYC = "kyc", SYSTEM = "system", + INVOICE_REJECTED = "invoice_rejected", } diff --git a/src/utils/cursor-pagination.utils.ts b/src/utils/cursor-pagination.utils.ts index 0bc9b00..a6e98d6 100644 --- a/src/utils/cursor-pagination.utils.ts +++ b/src/utils/cursor-pagination.utils.ts @@ -11,7 +11,7 @@ * @returns A base64-encoded cursor string */ export function encodeCursor(date: Date, id: string): string { - return Buffer.from(`${date.toISOString()}::${id}`).toString("base64"); + return Buffer.from(`${date.toISOString()}::${id}`).toString("base64"); } /** @@ -22,25 +22,25 @@ export function encodeCursor(date: Date, id: string): string { * @throws If the cursor format is invalid */ export function decodeCursor(cursor: string): { createdAt: Date; id: string } { - const decoded = Buffer.from(cursor, "base64").toString("utf-8"); - const separatorIndex = decoded.indexOf("::"); + const decoded = Buffer.from(cursor, "base64").toString("utf-8"); + const separatorIndex = decoded.indexOf("::"); - if (separatorIndex === -1) { - throw new Error("Invalid cursor format: expected 'ISO_DATE::ID'"); - } + if (separatorIndex === -1) { + throw new Error("Invalid cursor format: expected 'ISO_DATE::ID'"); + } - const createdAtIso = decoded.slice(0, separatorIndex); - const id = decoded.slice(separatorIndex + 2); + const createdAtIso = decoded.slice(0, separatorIndex); + const id = decoded.slice(separatorIndex + 2); - if (!createdAtIso || !id) { - throw new Error("Invalid cursor format: missing date or id component"); - } + if (!createdAtIso || !id) { + throw new Error("Invalid cursor format: missing date or id component"); + } - const createdAt = new Date(createdAtIso); + const createdAt = new Date(createdAtIso); - if (isNaN(createdAt.getTime())) { - throw new Error("Invalid cursor format: unable to parse date"); - } + if (isNaN(createdAt.getTime())) { + throw new Error("Invalid cursor format: unable to parse date"); + } - return { createdAt, id }; + return { createdAt, id }; } diff --git a/src/utils/discount-calculator.utils.ts b/src/utils/discount-calculator.utils.ts index 74364c4..c2ed8c2 100644 --- a/src/utils/discount-calculator.utils.ts +++ b/src/utils/discount-calculator.utils.ts @@ -25,7 +25,7 @@ export interface InvoiceTermsResult { */ export function calculateTenureDays( dueDateInput: Date | string, - referenceDateInput?: Date | string, + referenceDateInput?: Date | string ): number { const due = typeof dueDateInput === "string" ? new Date(dueDateInput) : dueDateInput; const ref = referenceDateInput @@ -50,7 +50,13 @@ export function calculateTenureDays( * Pure calculation utility for invoice discounting terms, fees, net advance amount, and annualized APR. */ export function calculateInvoiceTerms(input: InvoiceTermsInput): InvoiceTermsResult { - const { faceValue: rawFaceValue, dueDate, discountBps, platformFeeBps = 0, referenceDate } = input; + const { + faceValue: rawFaceValue, + dueDate, + discountBps, + platformFeeBps = 0, + referenceDate, + } = input; const faceValue = new Decimal(rawFaceValue); if (faceValue.isNegative() || faceValue.isZero()) { @@ -78,11 +84,7 @@ export function calculateInvoiceTerms(input: InvoiceTermsInput): InvoiceTermsRes // APR = (discountAmount / advanceAmount) * (365 / tenureDays) * 100 let apr = new Decimal(0); if (advanceAmount.gt(0) && discountAmount.gt(0) && tenureDays > 0) { - apr = discountAmount - .dividedBy(advanceAmount) - .times(365) - .dividedBy(tenureDays) - .times(100); + apr = discountAmount.dividedBy(advanceAmount).times(365).dividedBy(tenureDays).times(100); } return { diff --git a/src/utils/fee-calculator.utils.ts b/src/utils/fee-calculator.utils.ts index 0187266..11879f6 100644 --- a/src/utils/fee-calculator.utils.ts +++ b/src/utils/fee-calculator.utils.ts @@ -3,10 +3,7 @@ import Decimal from "decimal.js"; const BASIS_POINTS_DIVISOR = new Decimal(10_000); const MONEY_DECIMAL_PLACES = 2; -export function calculatePlatformFee( - amount: Decimal, - feeBps: number, -): Decimal { +export function calculatePlatformFee(amount: Decimal, feeBps: number): Decimal { return amount .mul(feeBps) .div(BASIS_POINTS_DIVISOR) diff --git a/src/utils/horizon-response.ts b/src/utils/horizon-response.ts index b01b386..d3d37f7 100644 --- a/src/utils/horizon-response.ts +++ b/src/utils/horizon-response.ts @@ -30,7 +30,11 @@ function optionalString(value: unknown): string | null { } export function normalizeHorizonTransaction(value: unknown): NormalizedHorizonTransaction { - if (typeof value !== "object" || value === null || typeof (value as { successful?: unknown }).successful !== "boolean") { + if ( + typeof value !== "object" || + value === null || + typeof (value as { successful?: unknown }).successful !== "boolean" + ) { throw new HorizonValidationError("Horizon transaction is missing required successful field"); } const transaction = value as Record; @@ -47,8 +51,14 @@ export function normalizeHorizonPayment(value: unknown): NormalizedHorizonPaymen throw new HorizonValidationError("Horizon payment must be an object"); } const payment = value as Record; - if (typeof payment.type !== "string" || typeof payment.amount !== "string" || typeof payment.to !== "string") { - throw new HorizonValidationError("Horizon payment is missing required type, amount, or destination"); + if ( + typeof payment.type !== "string" || + typeof payment.amount !== "string" || + typeof payment.to !== "string" + ) { + throw new HorizonValidationError( + "Horizon payment is missing required type, amount, or destination" + ); } return { id: optionalString(payment.id), diff --git a/src/utils/http-error.ts b/src/utils/http-error.ts index cbd55ee..82bd59a 100644 --- a/src/utils/http-error.ts +++ b/src/utils/http-error.ts @@ -16,7 +16,6 @@ export interface ApiResponseEnvelope { }; } - // ---------------- APP ERROR ---------------- export class AppError extends Error { @@ -24,12 +23,7 @@ export class AppError extends Error { code: string; details?: unknown; - constructor( - statusCode: number, - message: string, - code: string, - details?: unknown - ) { + constructor(statusCode: number, message: string, code: string, details?: unknown) { super(message); this.name = "AppError"; this.statusCode = statusCode; @@ -38,7 +32,6 @@ export class AppError extends Error { } } - // ---------------- HTTP ERROR ---------------- export class HttpError extends Error { @@ -46,15 +39,11 @@ export class HttpError extends Error { code: string; details?: unknown; - constructor( - statusCode: number, - message: string, - details?: unknown - ) { + constructor(statusCode: number, message: string, details?: unknown) { super(message); this.name = "HttpError"; this.statusCode = statusCode; this.code = `HTTP_${statusCode}`; this.details = details; } -} \ No newline at end of file +} diff --git a/src/utils/invoice-state.utils.ts b/src/utils/invoice-state.utils.ts index 5997004..0a0c3c3 100644 --- a/src/utils/invoice-state.utils.ts +++ b/src/utils/invoice-state.utils.ts @@ -6,7 +6,11 @@ export function isValidInvoiceStateTransition( ): boolean { const validTransitions: Record = { [InvoiceStatus.DRAFT]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED], - [InvoiceStatus.PENDING]: [InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED], + [InvoiceStatus.PENDING]: [ + InvoiceStatus.PUBLISHED, + InvoiceStatus.CANCELLED, + InvoiceStatus.REJECTED, + ], [InvoiceStatus.PUBLISHED]: [InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED], [InvoiceStatus.FUNDED]: [InvoiceStatus.SETTLED, InvoiceStatus.CANCELLED], [InvoiceStatus.SETTLED]: [InvoiceStatus.CANCELLED], diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts index d82f93c..2ff56e6 100644 --- a/src/utils/pagination.ts +++ b/src/utils/pagination.ts @@ -42,11 +42,11 @@ export async function queryInvoicesPage( if (filters.status) { if (Array.isArray(filters.status)) { - if (filters.status.length > 0) { - queryBuilder.andWhere("invoice.status IN (:...statuses)", { statuses: filters.status }); - } + if (filters.status.length > 0) { + queryBuilder.andWhere("invoice.status IN (:...statuses)", { statuses: filters.status }); + } } else { - queryBuilder.andWhere("invoice.status = :status", { status: filters.status }); + queryBuilder.andWhere("invoice.status = :status", { status: filters.status }); } } @@ -83,12 +83,14 @@ export async function queryInvoicesPage( let nextCursor: string | null = null; if (items.length > 0) { const lastItem = items[items.length - 1]; - nextCursor = Buffer.from(`${lastItem.createdAt.toISOString()}|${lastItem.id}`).toString("base64"); + nextCursor = Buffer.from(`${lastItem.createdAt.toISOString()}|${lastItem.id}`).toString( + "base64" + ); } return { data: items, has_more: hasMore, - next_cursor: nextCursor + next_cursor: nextCursor, }; } diff --git a/src/utils/query-pagination.utils.ts b/src/utils/query-pagination.utils.ts index 472ec7e..792b1b9 100644 --- a/src/utils/query-pagination.utils.ts +++ b/src/utils/query-pagination.utils.ts @@ -67,11 +67,11 @@ function invalidCursor(message: string): ServiceError { export function encodeQueryCursor( field: string, value: string | number | Date, - id?: string, + id?: string ): string { const normalizedValue = value instanceof Date ? value.toISOString() : value; return Buffer.from( - JSON.stringify({ field, value: normalizedValue, ...(id !== undefined ? { id } : {}) }), + JSON.stringify({ field, value: normalizedValue, ...(id !== undefined ? { id } : {}) }) ).toString("base64"); } @@ -125,7 +125,7 @@ function splitCursorField(cursorField: string): { alias: string; column: string const parts = cursorField.split("."); if (parts.length !== 2 || !parts[0] || !parts[1]) { throw invalidCursor( - `Invalid cursorField "${cursorField}": expected "." (e.g. "invoice.createdAt")`, + `Invalid cursorField "${cursorField}": expected "." (e.g. "invoice.createdAt")` ); } return { alias: parts[0], column: parts[1] }; @@ -146,7 +146,7 @@ function splitCursorField(cursorField: string): { alias: string; column: string * secondary sort, so equal primary sort values never produce gaps or repeats. */ export async function paginateQuery( - options: PaginateQueryOptions, + options: PaginateQueryOptions ): Promise> { const { queryBuilder, cursorField, limit, cursor } = options; const order = options.order ?? "DESC"; @@ -164,7 +164,7 @@ export async function paginateQuery( const decoded = decodeQueryCursor(cursor); if (decoded.field !== cursorField) { throw invalidCursor( - `Invalid cursor: was encoded for field "${decoded.field}" but query is paginating on "${cursorField}"`, + `Invalid cursor: was encoded for field "${decoded.field}" but query is paginating on "${cursorField}"` ); } @@ -183,7 +183,7 @@ export async function paginateQuery( { [paramName]: decoded.value, [tiebreakerParamName]: decoded.id, - }, + } ); } } @@ -204,16 +204,20 @@ export async function paginateQuery( if (hasMore && items.length > 0) { const lastItem = items[items.length - 1] as unknown as Record; const lastValue = lastItem[columnOf(cursorField)]; - if (typeof lastValue === "string" || typeof lastValue === "number" || lastValue instanceof Date) { + if ( + typeof lastValue === "string" || + typeof lastValue === "number" || + lastValue instanceof Date + ) { const lastId = lastItem[idColumn]; nextCursor = encodeQueryCursor( cursorField, lastValue, - typeof lastId === "string" ? lastId : undefined, + typeof lastId === "string" ? lastId : undefined ); } else { throw invalidCursor( - `paginateQuery: cursor column "${columnOf(cursorField)}" must resolve to a string, number, or Date (got ${typeof lastValue})`, + `paginateQuery: cursor column "${columnOf(cursorField)}" must resolve to a string, number, or Date (got ${typeof lastValue})` ); } } diff --git a/src/utils/response-envelope.utils.ts b/src/utils/response-envelope.utils.ts index 89004ea..f9d3c9d 100644 --- a/src/utils/response-envelope.utils.ts +++ b/src/utils/response-envelope.utils.ts @@ -8,28 +8,28 @@ * @returns A paginated response envelope object */ export function buildPaginatedResponse( - items: T[], - totalCount: number, - limit: number, - nextCursor?: string, + items: T[], + totalCount: number, + limit: number, + nextCursor?: string ): { - success: true; - data: T[]; - meta: { - total: number; - limit: number; - hasNextPage: boolean; - nextCursor: string | null; - }; + success: true; + data: T[]; + meta: { + total: number; + limit: number; + hasNextPage: boolean; + nextCursor: string | null; + }; } { - return { - success: true, - data: items, - meta: { - total: totalCount, - limit, - hasNextPage: Boolean(nextCursor), - nextCursor: nextCursor || null, - }, - }; -} \ No newline at end of file + return { + success: true, + data: items, + meta: { + total: totalCount, + limit, + hasNextPage: Boolean(nextCursor), + nextCursor: nextCursor || null, + }, + }; +} diff --git a/src/utils/stellar-challenge.ts b/src/utils/stellar-challenge.ts index 26338a3..d04d400 100644 --- a/src/utils/stellar-challenge.ts +++ b/src/utils/stellar-challenge.ts @@ -18,7 +18,7 @@ export interface WalletChallenge { export function buildWalletChallenge( walletAddress: string, networkPassphrase: string, - serverKeypair: Keypair, + serverKeypair: Keypair ): WalletChallenge { if (!StrKey.isValidEd25519PublicKey(walletAddress)) { throw new HttpError(400, "Invalid wallet address."); @@ -36,7 +36,7 @@ export function buildWalletChallenge( name: "web_auth_domain", value: Buffer.from(nonce, "utf8"), source: walletAddress, - }), + }) ) .setTimeout(300) .build(); diff --git a/src/utils/webhook-signature.ts b/src/utils/webhook-signature.ts index 214f428..d4802be 100644 --- a/src/utils/webhook-signature.ts +++ b/src/utils/webhook-signature.ts @@ -19,7 +19,7 @@ export function computeWebhookSignature(payload: string, secret: string): string export function verifyWebhookSignature( payload: string, signature: string, - secret: string, + secret: string ): boolean { try { if (!payload || !signature || !secret) { @@ -57,7 +57,7 @@ export type WebhookSignatureErrorCode = export class WebhookSignatureError extends Error { constructor( public readonly code: WebhookSignatureErrorCode, - message: string, + message: string ) { super(message); this.name = "WebhookSignatureError"; @@ -77,7 +77,7 @@ export const DEFAULT_MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000; export function computeWebhookSignatureForTimestamp( payload: string, timestamp: string, - secret: string, + secret: string ): string { return computeWebhookSignature(`${timestamp}.${payload}`, secret); } @@ -112,9 +112,7 @@ export interface VerifyWebhookHeadersOptions { * failure. Never returns `false` — every rejection is a typed error that a * handler can map to a 4xx response. */ -export function verifyWebhookSignatureHeaders( - options: VerifyWebhookHeadersOptions, -): string { +export function verifyWebhookSignatureHeaders(options: VerifyWebhookHeadersOptions): string { const { payload, signature, @@ -125,23 +123,20 @@ export function verifyWebhookSignatureHeaders( } = options; if (!secret) { - throw new WebhookSignatureError( - "INVALID_SIGNATURE", - "Webhook secret is not configured.", - ); + throw new WebhookSignatureError("INVALID_SIGNATURE", "Webhook secret is not configured."); } if (timestamp === undefined || timestamp === null || timestamp === "") { throw new WebhookSignatureError( "MISSING_TIMESTAMP", - `Missing '${WEBHOOK_TIMESTAMP_HEADER}' header.`, + `Missing '${WEBHOOK_TIMESTAMP_HEADER}' header.` ); } if (signature === undefined || signature === null || signature === "") { throw new WebhookSignatureError( "MISSING_SIGNATURE", - `Missing '${WEBHOOK_SIGNATURE_HEADER}' header.`, + `Missing '${WEBHOOK_SIGNATURE_HEADER}' header.` ); } @@ -150,14 +145,14 @@ export function verifyWebhookSignatureHeaders( if (Number.isNaN(skewMs) || !Number.isFinite(skewMs)) { throw new WebhookSignatureError( "TIMESTAMP_OUT_OF_RANGE", - `'${WEBHOOK_TIMESTAMP_HEADER}' is not a valid timestamp.`, + `'${WEBHOOK_TIMESTAMP_HEADER}' is not a valid timestamp.` ); } if (Math.abs(skewMs) > maxTimestampSkewMs) { throw new WebhookSignatureError( "TIMESTAMP_SKEWED", - `'${WEBHOOK_TIMESTAMP_HEADER}' is outside the allowed ${maxTimestampSkewMs}ms window.`, + `'${WEBHOOK_TIMESTAMP_HEADER}' is outside the allowed ${maxTimestampSkewMs}ms window.` ); } @@ -165,7 +160,7 @@ export function verifyWebhookSignatureHeaders( if (!/^[0-9a-f]{64}$/i.test(signature)) { throw new WebhookSignatureError( "MALFORMED_SIGNATURE", - "'x-webhook-signature' is not a valid HMAC-SHA256 hex signature.", + "'x-webhook-signature' is not a valid HMAC-SHA256 hex signature." ); } @@ -174,20 +169,14 @@ export function verifyWebhookSignatureHeaders( if (expected.length !== signature.length) { throw new WebhookSignatureError( "MALFORMED_SIGNATURE", - "'x-webhook-signature' has an invalid length.", + "'x-webhook-signature' has an invalid length." ); } - const matches = crypto.timingSafeEqual( - Buffer.from(expected), - Buffer.from(signature), - ); + const matches = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); if (!matches) { - throw new WebhookSignatureError( - "INVALID_SIGNATURE", - "Webhook signature verification failed.", - ); + throw new WebhookSignatureError("INVALID_SIGNATURE", "Webhook signature verification failed."); } return timestamp; diff --git a/src/workers/reconcile-pending-stellar-state.worker.ts b/src/workers/reconcile-pending-stellar-state.worker.ts index c648ac8..b19b780 100644 --- a/src/workers/reconcile-pending-stellar-state.worker.ts +++ b/src/workers/reconcile-pending-stellar-state.worker.ts @@ -90,8 +90,7 @@ export class ReconcilePendingStellarStateWorker { }); this.now = dependencies.now ?? (() => new Date()); this.yieldControl = - dependencies.yieldControl ?? - (() => new Promise((resolve) => setImmediate(resolve))); + dependencies.yieldControl ?? (() => new Promise((resolve) => setImmediate(resolve))); this.setIntervalFn = dependencies.setIntervalFn ?? setInterval; this.clearIntervalFn = dependencies.clearIntervalFn ?? clearInterval; } @@ -134,10 +133,7 @@ export class ReconcilePendingStellarStateWorker { const deadline = startedAt.getTime() + this.config.maxRuntimeMs; try { - const candidates = await this.repository.findPendingCandidates( - cutoff, - this.config.batchSize, - ); + const candidates = await this.repository.findPendingCandidates(cutoff, this.config.batchSize); this.attemptTracker.clear(); @@ -259,12 +255,10 @@ export class ReconcilePendingStellarStateWorker { } } -class TypeOrmReconciliationCandidateRepository - implements ReconciliationCandidateRepository -{ +class TypeOrmReconciliationCandidateRepository implements ReconciliationCandidateRepository { constructor( private readonly investmentRepository: Repository, - private readonly transactionRepository: Repository, + private readonly transactionRepository: Repository ) {} async findPendingCandidates(olderThan: Date, limit: number): Promise { @@ -337,12 +331,12 @@ export function createReconcilePendingStellarStateWorker( dataSource: DataSource, paymentVerifier: VerifyPaymentService, config: AppConfig["reconciliation"], - logger: AppLogger, + logger: AppLogger ): ReconcilePendingStellarStateWorker { return new ReconcilePendingStellarStateWorker({ repository: new TypeOrmReconciliationCandidateRepository( dataSource.getRepository(Investment), - dataSource.getRepository(Transaction), + dataSource.getRepository(Transaction) ), paymentVerifier, config, diff --git a/tests/api-envelope.test.ts b/tests/api-envelope.test.ts index 0c03b9f..1c06c4b 100644 --- a/tests/api-envelope.test.ts +++ b/tests/api-envelope.test.ts @@ -79,7 +79,7 @@ describe("Response envelope", () => { // Add a route that throws an error before the error middleware is applied // We use the router pattern to ensure the route is matched before notFoundMiddleware const router = request.agent(app); - + // Simulate an internal error by accessing a route that will throw // Since we can't add routes after app creation, we'll test via the auth routes // which will throw an error from the stub service diff --git a/tests/app-hardening.test.ts b/tests/app-hardening.test.ts index fa3cab6..3d000de 100644 --- a/tests/app-hardening.test.ts +++ b/tests/app-hardening.test.ts @@ -52,7 +52,7 @@ describe("App hardening", () => { expect(app.get("trust proxy")).toBe(1); expect(response.headers["access-control-allow-origin"]).toBe( - "https://app.stellarstate.example", + "https://app.stellarstate.example" ); }); diff --git a/tests/auth-jwt-log.test.ts b/tests/auth-jwt-log.test.ts index 11a6267..e5dc141 100644 --- a/tests/auth-jwt-log.test.ts +++ b/tests/auth-jwt-log.test.ts @@ -34,9 +34,47 @@ class InMemoryUserRepository implements UserRepositoryContract { } async findByStellarAddress(stellarAddress: string) { - return ( - [...this.users.values()].find((u) => u.stellarAddress === stellarAddress) ?? null + return [...this.users.values()].find((u) => u.stellarAddress === stellarAddress) ?? null; + } + + async findByEmail(email: string) { + return [...this.users.values()].find((u) => u.email === email) ?? null; + } + + async findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }) { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + results.sort((a, b) => + options?.order === "ASC" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id) ); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(cursorIndex + 1); + } + } + if (options?.skip) { + results = results.slice(options.skip); + } + if (options?.take) { + results = results.slice(0, options.take); + } + return results; + } + + async count(options?: { cursor?: string }): Promise { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(0, cursorIndex); + } + } + return results.length; } async save(user: Partial) { @@ -77,7 +115,7 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { async findByAddressAndNonceHash(stellarAddress: string, nonceHash: string) { return ( [...this.challenges.values()].find( - (c) => c.stellarAddress === stellarAddress && c.nonceHash === nonceHash, + (c) => c.stellarAddress === stellarAddress && c.nonceHash === nonceHash ) ?? null ); } @@ -88,6 +126,28 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { challenge.consumedAt = consumedAt; return true; } + + async deleteExpired(before: Date): Promise { + let count = 0; + for (const [id, challenge] of this.challenges.entries()) { + if (challenge.expiresAt < before || (challenge.consumedAt && challenge.consumedAt < before)) { + this.challenges.delete(id); + count++; + } + } + return count; + } + + async countByStatus(status: "active" | "consumed" | "expired"): Promise { + const now = new Date(); + let count = 0; + for (const challenge of this.challenges.values()) { + if (status === "active" && !challenge.consumedAt && challenge.expiresAt > now) count++; + if (status === "consumed" && challenge.consumedAt) count++; + if (status === "expired" && !challenge.consumedAt && challenge.expiresAt <= now) count++; + } + return count; + } } // ── CaptureLogger ── @@ -143,7 +203,7 @@ function createTestServer(logger?: AppLogger) { async function completeAuthFlow( app: ReturnType, keypair: Keypair, - extraHeaders: Record = {}, + extraHeaders: Record = {} ) { const challengeRes = await request(app) .post("/api/v1/auth/challenge") @@ -171,7 +231,7 @@ describe("JWT issuance structured log (issue #111)", () => { expect(res.status).toBe(200); const jwtLog = captureLogger.entries.find( - (e) => e.level === "info" && e.message === "jwt.issued", + (e) => e.level === "info" && e.message === "jwt.issued" ); expect(jwtLog).toBeDefined(); diff --git a/tests/auth-jwt-subject-expiry.test.ts b/tests/auth-jwt-subject-expiry.test.ts index ac6a45f..9264f6b 100644 --- a/tests/auth-jwt-subject-expiry.test.ts +++ b/tests/auth-jwt-subject-expiry.test.ts @@ -3,8 +3,8 @@ import jwt from "jsonwebtoken"; import { Keypair, Networks } from "stellar-sdk"; import { AuthService } from "../src/services/auth.service"; import type { - ChallengeRepositoryContract, - UserRepositoryContract, + ChallengeRepositoryContract, + UserRepositoryContract, } from "../src/services/auth.service"; import { KYCStatus, UserType } from "../src/types/enums"; import { HttpError } from "../src/utils/http-error"; @@ -20,296 +20,355 @@ import { User } from "../src/models/User.model"; type InMemoryUser = User; interface InMemoryChallenge { - id: string; - stellarAddress: string; - nonceHash: string; - message: string; - network: string; - issuedAt: Date; - expiresAt: Date; - consumedAt: Date | null; + id: string; + stellarAddress: string; + nonceHash: string; + message: string; + network: string; + issuedAt: Date; + expiresAt: Date; + consumedAt: Date | null; } class InMemoryUserRepository implements UserRepositoryContract { - private readonly users = new Map(); - - async findById(id: string) { - return this.users.get(id) ?? null; + private readonly users = new Map(); + + async findById(id: string) { + return this.users.get(id) ?? null; + } + + async findByStellarAddress(stellarAddress: string) { + return [...this.users.values()].find((user) => user.stellarAddress === stellarAddress) ?? null; + } + + async findByEmail(email: string) { + return [...this.users.values()].find((u) => u.email === email) ?? null; + } + + async findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }) { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + results.sort((a, b) => + options?.order === "ASC" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id) + ); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(cursorIndex + 1); + } } - - async findByStellarAddress(stellarAddress: string) { - return ( - [...this.users.values()].find( - (user) => user.stellarAddress === stellarAddress, - ) ?? null - ); + if (options?.skip) { + results = results.slice(options.skip); } - - async save(user: Partial) { - const now = new Date(); - const entity: InMemoryUser = { - id: crypto.randomUUID(), - stellarAddress: user.stellarAddress ?? "", - email: user.email ?? null, - userType: user.userType ?? UserType.INVESTOR, - kycStatus: user.kycStatus ?? KYCStatus.PENDING, - createdAt: user.createdAt ?? now, - updatedAt: user.updatedAt ?? now, - deletedAt: user.deletedAt ?? null, - invoices: user.invoices ?? [], - investments: user.investments ?? [], - transactions: user.transactions ?? [], - kycVerifications: user.kycVerifications ?? [], - notifications: user.notifications ?? [], - }; - - this.users.set(entity.id, entity); - return entity; + if (options?.take) { + results = results.slice(0, options.take); + } + return results; + } + + async count(options?: { cursor?: string }): Promise { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(0, cursorIndex); + } } + return results.length; + } + + async save(user: Partial) { + const now = new Date(); + const entity: InMemoryUser = { + id: crypto.randomUUID(), + stellarAddress: user.stellarAddress ?? "", + email: user.email ?? null, + userType: user.userType ?? UserType.INVESTOR, + kycStatus: user.kycStatus ?? KYCStatus.PENDING, + createdAt: user.createdAt ?? now, + updatedAt: user.updatedAt ?? now, + deletedAt: user.deletedAt ?? null, + invoices: user.invoices ?? [], + investments: user.investments ?? [], + transactions: user.transactions ?? [], + kycVerifications: user.kycVerifications ?? [], + notifications: user.notifications ?? [], + }; + + this.users.set(entity.id, entity); + return entity; + } } class InMemoryChallengeRepository implements ChallengeRepositoryContract { - readonly challenges = new Map(); - - async create(input: InMemoryChallenge) { - const challenge: InMemoryChallenge = { - id: crypto.randomUUID(), - stellarAddress: input.stellarAddress, - nonceHash: input.nonceHash, - message: input.message, - network: input.network, - issuedAt: input.issuedAt, - expiresAt: input.expiresAt, - consumedAt: null, - }; - - this.challenges.set(challenge.id, challenge); - return challenge; - } + readonly challenges = new Map(); + + async create(input: InMemoryChallenge) { + const challenge: InMemoryChallenge = { + id: crypto.randomUUID(), + stellarAddress: input.stellarAddress, + nonceHash: input.nonceHash, + message: input.message, + network: input.network, + issuedAt: input.issuedAt, + expiresAt: input.expiresAt, + consumedAt: null, + }; - async findByAddressAndNonceHash(stellarAddress: string, nonceHash: string) { - return ( - [...this.challenges.values()].find( - (challenge) => - challenge.stellarAddress === stellarAddress && - challenge.nonceHash === nonceHash, - ) ?? null - ); - } + this.challenges.set(challenge.id, challenge); + return challenge; + } - async consume(id: string, consumedAt: Date) { - const challenge = this.challenges.get(id); + async findByAddressAndNonceHash(stellarAddress: string, nonceHash: string) { + return ( + [...this.challenges.values()].find( + (challenge) => + challenge.stellarAddress === stellarAddress && challenge.nonceHash === nonceHash + ) ?? null + ); + } - if (!challenge || challenge.consumedAt) { - return false; - } + async consume(id: string, consumedAt: Date) { + const challenge = this.challenges.get(id); + + if (!challenge || challenge.consumedAt) { + return false; + } - challenge.consumedAt = consumedAt; - return true; + challenge.consumedAt = consumedAt; + return true; + } + + async deleteExpired(before: Date): Promise { + let count = 0; + for (const [id, challenge] of this.challenges.entries()) { + if (challenge.expiresAt < before || (challenge.consumedAt && challenge.consumedAt < before)) { + this.challenges.delete(id); + count++; + } } + return count; + } + + async countByStatus(status: "active" | "consumed" | "expired"): Promise { + const now = new Date(); + let count = 0; + for (const challenge of this.challenges.values()) { + if (status === "active" && !challenge.consumedAt && challenge.expiresAt > now) count++; + if (status === "consumed" && challenge.consumedAt) count++; + if (status === "expired" && !challenge.consumedAt && challenge.expiresAt <= now) count++; + } + return count; + } } // ── Helpers ── const TEST_SECRET = "test-secret-for-jwt-tests"; -function createAuthServiceWithTtl( - ttlString: string, - challengeTtlMs = 60_000, -): AuthService { - return new AuthService({ - userRepository: new InMemoryUserRepository(), - challengeRepository: new InMemoryChallengeRepository(), - config: { - jwt: { - secret: TEST_SECRET, - expiresIn: ttlString, - }, - auth: { - challengeTtlMs, - }, - stellar: { - network: "testnet", - networkPassphrase: Networks.TESTNET, - }, - }, - }); +function createAuthServiceWithTtl(ttlString: string, challengeTtlMs = 60_000): AuthService { + return new AuthService({ + userRepository: new InMemoryUserRepository(), + challengeRepository: new InMemoryChallengeRepository(), + config: { + jwt: { + secret: TEST_SECRET, + expiresIn: ttlString, + }, + auth: { + challengeTtlMs, + }, + stellar: { + network: "testnet", + networkPassphrase: Networks.TESTNET, + }, + }, + }); } function createTestApp(ttlString = "15m") { - const userRepository = new InMemoryUserRepository(); - const challengeRepository = new InMemoryChallengeRepository(); - const authService = new AuthService({ - userRepository, - challengeRepository, - config: { - jwt: { - secret: TEST_SECRET, - expiresIn: ttlString, - }, - auth: { - challengeTtlMs: 60_000, - }, - stellar: { - network: "testnet", - networkPassphrase: Networks.TESTNET, - }, - }, - }); - - return { - app: createApp({ authService }), - challengeRepository, - authService, - }; + const userRepository = new InMemoryUserRepository(); + const challengeRepository = new InMemoryChallengeRepository(); + const authService = new AuthService({ + userRepository, + challengeRepository, + config: { + jwt: { + secret: TEST_SECRET, + expiresIn: ttlString, + }, + auth: { + challengeTtlMs: 60_000, + }, + stellar: { + network: "testnet", + networkPassphrase: Networks.TESTNET, + }, + }, + }); + + return { + app: createApp({ authService }), + challengeRepository, + authService, + }; } // ── Unit Tests (AuthService internals) ── describe("JWT subject claim", () => { - it("should have sub equal to the wallet address used in the challenge", async () => { - const { app } = createTestApp(); - const keypair = Keypair.random(); - const walletAddress = keypair.publicKey(); - - // Complete full auth flow - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: walletAddress }) - .expect(201); - - const { nonce, message } = challengeRes.body.challenge; - const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); - - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: walletAddress, - nonce, - signature, - }) - .expect(200); - - const token = verifyRes.body.token; - - // Decode the token without verification to inspect the payload - const decoded = jwt.decode(token) as jwt.JwtPayload; - expect(decoded).not.toBeNull(); - expect(decoded!.sub).toBe(walletAddress); - }); + it("should have sub equal to the wallet address used in the challenge", async () => { + const { app } = createTestApp(); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + // Complete full auth flow + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const token = verifyRes.body.token; + + // Decode the token without verification to inspect the payload + const decoded = jwt.decode(token) as jwt.JwtPayload; + expect(decoded).not.toBeNull(); + expect(decoded!.sub).toBe(walletAddress); + }); }); describe("JWT expiry", () => { - it("should be within 1 second of now + configured TTL", async () => { - const { app } = createTestApp("1h"); - const keypair = Keypair.random(); - const walletAddress = keypair.publicKey(); - - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: walletAddress }) - .expect(201); - - const { nonce, message } = challengeRes.body.challenge; - const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); - - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: walletAddress, - nonce, - signature, - }) - .expect(200); - - const token = verifyRes.body.token; - - // Decode to inspect exp claim - const decoded = jwt.decode(token) as jwt.JwtPayload; - expect(decoded).not.toBeNull(); - expect(decoded!.exp).toBeDefined(); - - const now = Math.floor(Date.now() / 1000); - const expectedExp = now + 3600; // 1h = 3600s - - // Allow 1 second tolerance - expect(Math.abs(decoded!.exp! - expectedExp)).toBeLessThanOrEqual(1); - }); + it("should be within 1 second of now + configured TTL", async () => { + const { app } = createTestApp("1h"); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const token = verifyRes.body.token; + + // Decode to inspect exp claim + const decoded = jwt.decode(token) as jwt.JwtPayload; + expect(decoded).not.toBeNull(); + expect(decoded!.exp).toBeDefined(); + + const now = Math.floor(Date.now() / 1000); + const expectedExp = now + 3600; // 1h = 3600s + + // Allow 1 second tolerance + expect(Math.abs(decoded!.exp! - expectedExp)).toBeLessThanOrEqual(1); + }); }); describe("Tampered JWT", () => { - it("should be rejected with 401 by the verification middleware", async () => { - const { app } = createTestApp(); - const keypair = Keypair.random(); - const walletAddress = keypair.publicKey(); - - // Get a valid token first - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: walletAddress }) - .expect(201); - - const { nonce, message } = challengeRes.body.challenge; - const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); - - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: walletAddress, - nonce, - signature, - }) - .expect(200); - - const validToken = verifyRes.body.token; - - // Tamper with the payload by modifying the second part of the JWT - const parts = validToken.split("."); - const tamperedPayload = Buffer.from( - JSON.stringify({ sub: "tampered-wallet", stellarAddress: "tampered", iat: 0, exp: 9999999999 }), - ).toString("base64url"); - const tamperedToken = [parts[0], tamperedPayload, parts[2]].join("."); - - // Hit /me with the tampered token - await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${tamperedToken}`) - .expect(401); - }); + it("should be rejected with 401 by the verification middleware", async () => { + const { app } = createTestApp(); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + // Get a valid token first + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const validToken = verifyRes.body.token; + + // Tamper with the payload by modifying the second part of the JWT + const parts = validToken.split("."); + const tamperedPayload = Buffer.from( + JSON.stringify({ + sub: "tampered-wallet", + stellarAddress: "tampered", + iat: 0, + exp: 9999999999, + }) + ).toString("base64url"); + const tamperedToken = [parts[0], tamperedPayload, parts[2]].join("."); + + // Hit /me with the tampered token + await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${tamperedToken}`) + .expect(401); + }); }); describe("Expired JWT", () => { - it("should be rejected with 401 when TTL is set to -1s in test config", async () => { - // Create a service with a -1s TTL (effectively already expired) - const { app } = createTestApp("-1s"); - const keypair = Keypair.random(); - const walletAddress = keypair.publicKey(); - - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: walletAddress }) - .expect(201); - - const { nonce, message } = challengeRes.body.challenge; - const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); - - // The token should have exp in the past - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: walletAddress, - nonce, - signature, - }) - .expect(200); - - const expiredToken = verifyRes.body.token; - - // The verify endpoint may still issue the token (it just signs it), - // so the token itself will be expired. Now try to use it. - await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${expiredToken}`) - .expect(401); - }); -}); \ No newline at end of file + it("should be rejected with 401 when TTL is set to -1s in test config", async () => { + // Create a service with a -1s TTL (effectively already expired) + const { app } = createTestApp("-1s"); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + // The token should have exp in the past + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const expiredToken = verifyRes.body.token; + + // The verify endpoint may still issue the token (it just signs it), + // so the token itself will be expired. Now try to use it. + await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${expiredToken}`) + .expect(401); + }); +}); diff --git a/tests/auth.routes.test.ts b/tests/auth.routes.test.ts index d1ff9b4..7f451df 100644 --- a/tests/auth.routes.test.ts +++ b/tests/auth.routes.test.ts @@ -31,10 +31,47 @@ class InMemoryUserRepository implements UserRepositoryContract { } async findByStellarAddress(stellarAddress: string) { - return ( - [...this.users.values()].find((user) => user.stellarAddress === stellarAddress) ?? - null + return [...this.users.values()].find((user) => user.stellarAddress === stellarAddress) ?? null; + } + + async findByEmail(email: string) { + return [...this.users.values()].find((u) => u.email === email) ?? null; + } + + async findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }) { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + results.sort((a, b) => + options?.order === "ASC" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id) ); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(cursorIndex + 1); + } + } + if (options?.skip) { + results = results.slice(options.skip); + } + if (options?.take) { + results = results.slice(0, options.take); + } + return results; + } + + async count(options?: { cursor?: string }): Promise { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(0, cursorIndex); + } + } + return results.length; } async save(user: Partial) { @@ -83,8 +120,7 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { return ( [...this.challenges.values()].find( (challenge) => - challenge.stellarAddress === stellarAddress && - challenge.nonceHash === nonceHash, + challenge.stellarAddress === stellarAddress && challenge.nonceHash === nonceHash ) ?? null ); } @@ -99,6 +135,28 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { challenge.consumedAt = consumedAt; return true; } + + async deleteExpired(before: Date): Promise { + let count = 0; + for (const [id, challenge] of this.challenges.entries()) { + if (challenge.expiresAt < before || (challenge.consumedAt && challenge.consumedAt < before)) { + this.challenges.delete(id); + count++; + } + } + return count; + } + + async countByStatus(status: "active" | "consumed" | "expired"): Promise { + const now = new Date(); + let count = 0; + for (const challenge of this.challenges.values()) { + if (status === "active" && !challenge.consumedAt && challenge.expiresAt > now) count++; + if (status === "consumed" && challenge.consumedAt) count++; + if (status === "expired" && !challenge.consumedAt && challenge.expiresAt <= now) count++; + } + return count; + } } function createTestServer(challengeTtlMs = 60_000) { @@ -129,7 +187,7 @@ function createTestServer(challengeTtlMs = 60_000) { } afterEach(() => { -jest.useRealTimers(); + jest.useRealTimers(); }); describe("Auth routes", () => { @@ -281,8 +339,8 @@ describe("Auth routes", () => { expect( [...challengeRepository.challenges.values()].some( - (challenge) => challenge.consumedAt !== null, - ), + (challenge) => challenge.consumedAt !== null + ) ).toBe(true); }); @@ -342,4 +400,4 @@ describe("Auth routes", () => { }, }); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/full-flow.e2e.test.ts b/tests/e2e/full-flow.e2e.test.ts index 2fd96be..7f4f602 100644 --- a/tests/e2e/full-flow.e2e.test.ts +++ b/tests/e2e/full-flow.e2e.test.ts @@ -211,6 +211,47 @@ describe("E2E: Complete Invoice Financing Flow", () => { }, }; + // Initialize test database (SQLite in-memory) + patchEntityMetadataForSQLite(); + + dataSource = new DataSource({ + type: "sqlite", + database: ":memory:", + synchronize: true, + logging: false, + entities: [ + User, + Invoice, + Investment, + AuthChallenge, + Transaction, + KYCVerification, + Notification, + ], + }); + + await dataSource.initialize(); + + // Create services with mocked IPFS + const authService = createAuthService(dataSource, config, logger); + const invoiceService = createInvoiceService(dataSource, mockIPFSService); + const investmentService = createInvestmentService(dataSource); + const settlementService = createSettlementService(dataSource); + const marketplaceService = createMarketplaceService(dataSource); + const notificationService = createNotificationService(dataSource); + + // Create the full app + app = createApp({ + authService, + notificationService, + invoiceService, + investmentService, + settlementService, + marketplaceService, + config, + logger, + metricsEnabled: false, + }); // Initialize test database (SQLite in-memory). Wrap the whole bring-up so a // failure in schema sync or service wiring surfaces with a clear cause // instead of every downstream test throwing an opaque "app is undefined". @@ -270,6 +311,53 @@ describe("E2E: Complete Invoice Financing Flow", () => { sellerToken = token; sellerId = userId; + expect(challengeRes.body.challenge).toBeDefined(); + expect(challengeRes.body.challenge.publicKey).toBe(sellerKeypair.publicKey()); + expect(challengeRes.body.challenge.nonce).toBeDefined(); + expect(challengeRes.body.challenge.message).toBeDefined(); + + const { nonce, message } = challengeRes.body.challenge; + + // Sign the challenge message + const signature = sellerKeypair.sign(Buffer.from(message, "utf8")).toString("hex"); + + // Verify challenge and get token + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: sellerKeypair.publicKey(), + nonce, + signature, + }) + .expect(200); + + expect(verifyRes.body.token).toBeDefined(); + expect(verifyRes.body.tokenType).toBe("Bearer"); + expect(verifyRes.body.user).toBeDefined(); + expect(verifyRes.body.user.stellarAddress).toBe(sellerKeypair.publicKey()); + + sellerToken = verifyRes.body.token; + sellerId = verifyRes.body.user.id; + }); + + it("should authenticate investor via Stellar challenge-response", async () => { + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: investorKeypair.publicKey() }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + + const signature = investorKeypair.sign(Buffer.from(message, "utf8")).toString("hex"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: investorKeypair.publicKey(), + nonce, + signature, + }) + .expect(200); expect(sellerToken).toEqual(expect.any(String)); expect(sellerId).toEqual(expect.any(String)); }); @@ -375,9 +463,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { expect(invoice).toBeDefined(); expect(invoice?.status).toBe(InvoiceStatus.PUBLISHED); expect(invoice?.sellerId).toBe(sellerId); - expect(invoice?.ipfsHash).toBe( - "QmMockHash1234567890123456789012345678901234567890" - ); + expect(invoice?.ipfsHash).toBe("QmMockHash1234567890123456789012345678901234567890"); }); }); @@ -395,9 +481,7 @@ describe("E2E: Complete Invoice Financing Flow", () => { expect(Array.isArray(marketplaceRes.body.data)).toBe(true); expect(marketplaceRes.body.data.length).toBeGreaterThan(0); - const listedInvoice = marketplaceRes.body.data.find( - (inv: any) => inv.id === invoiceId - ); + const listedInvoice = marketplaceRes.body.data.find((inv: any) => inv.id === invoiceId); expect(listedInvoice).toBeDefined(); expect(listedInvoice.invoiceNumber).toBe("INV-E2E-001"); expect(toNum(listedInvoice.amount)).toBeCloseTo(10000, 2); diff --git a/tests/funding-deadline-validator.test.ts b/tests/funding-deadline-validator.test.ts index 6ddf482..e1a4442 100644 --- a/tests/funding-deadline-validator.test.ts +++ b/tests/funding-deadline-validator.test.ts @@ -184,7 +184,7 @@ describe("validateInvoiceForPublish deadline reporting", () => { }); const codes = validateInvoiceForPublish(invoice, NOW).map((e) => e.code); expect(codes).toEqual( - expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_IN_PAST", "MISSING_DOCUMENT"]), + expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_IN_PAST", "MISSING_DOCUMENT"]) ); }); }); diff --git a/tests/integration/admin-invoice-reject.integration.test.ts b/tests/integration/admin-invoice-reject.integration.test.ts index e525c8b..dcf4e80 100644 --- a/tests/integration/admin-invoice-reject.integration.test.ts +++ b/tests/integration/admin-invoice-reject.integration.test.ts @@ -1,6 +1,9 @@ import crypto from "crypto"; import { InvoiceService } from "../../src/services/invoice.service"; -import type { InvoiceRepositoryContract, NotificationSink } from "../../src/services/invoice.service"; +import type { + InvoiceRepositoryContract, + NotificationSink, +} from "../../src/services/invoice.service"; import { Invoice } from "../../src/models/Invoice.model"; import { InvoiceStatus, NotificationType } from "../../src/types/enums"; import { ServiceError } from "../../src/utils/service-error"; @@ -40,8 +43,7 @@ class InMemoryInvoiceRepository implements InvoiceRepositoryContract { async findOneBy(options: { id?: string; invoiceNumber?: string }) { for (const invoice of this.invoices.values()) { if (options.id && invoice.id === options.id) return invoice; - if (options.invoiceNumber && invoice.invoiceNumber === options.invoiceNumber) - return invoice; + if (options.invoiceNumber && invoice.invoiceNumber === options.invoiceNumber) return invoice; } return null; } @@ -55,7 +57,7 @@ class InMemoryInvoiceRepository implements InvoiceRepositoryContract { return [...this.invoices.values()].filter( (inv) => inv.sellerId === options.where.sellerId && - (options.where.status == null || inv.status === options.where.status), + (options.where.status == null || inv.status === options.where.status) ); } @@ -117,7 +119,7 @@ function seedPendingInvoice(repo: InMemoryInvoiceRepository, sellerId: string): createdAt: now, updatedAt: now, deletedAt: null, - seller: undefined as unknown as Invoice["seller"], + seller: { id: sellerId, stellarAddress: "GTEST" } as unknown as Invoice["seller"], investments: [], transactions: [], } as Invoice; @@ -186,7 +188,7 @@ describe("Admin invoice reject: persists reason, transitions status, notifies se expect(notificationSink.sent).toHaveLength(1); await expect( - service.rejectInvoice({ invoiceId: invoice.id, rejectionReason: "Second reason" }), + service.rejectInvoice({ invoiceId: invoice.id, rejectionReason: "Second reason" }) ).rejects.toMatchObject({ code: "invoice_already_rejected", statusCode: 409, @@ -200,7 +202,7 @@ describe("Admin invoice reject: persists reason, transitions status, notifies se it("rejects with 404 when the invoice does not exist", async () => { await expect( - service.rejectInvoice({ invoiceId: crypto.randomUUID(), rejectionReason: "N/A" }), + service.rejectInvoice({ invoiceId: crypto.randomUUID(), rejectionReason: "N/A" }) ).rejects.toMatchObject({ code: "invoice_not_found", statusCode: 404 }); }); @@ -210,7 +212,7 @@ describe("Admin invoice reject: persists reason, transitions status, notifies se await repo.save(invoice); await expect( - service.rejectInvoice({ invoiceId: invoice.id, rejectionReason: "Too late" }), + service.rejectInvoice({ invoiceId: invoice.id, rejectionReason: "Too late" }) ).rejects.toMatchObject({ code: "invalid_status_transition", statusCode: 409, diff --git a/tests/integration/auth-jwt-validation.test.ts b/tests/integration/auth-jwt-validation.test.ts index 89ab16b..bfc84cb 100644 --- a/tests/integration/auth-jwt-validation.test.ts +++ b/tests/integration/auth-jwt-validation.test.ts @@ -35,11 +35,47 @@ class InMemoryUserRepository implements UserRepositoryContract { } async findByStellarAddress(stellarAddress: string) { - return ( - [...this.users.values()].find( - (user) => user.stellarAddress === stellarAddress, - ) ?? null + return [...this.users.values()].find((user) => user.stellarAddress === stellarAddress) ?? null; + } + + async findByEmail(email: string) { + return [...this.users.values()].find((u) => u.email === email) ?? null; + } + + async findAll(options?: { + skip?: number; + take?: number; + cursor?: string; + order?: "ASC" | "DESC"; + }) { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + results.sort((a, b) => + options?.order === "ASC" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id) ); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(cursorIndex + 1); + } + } + if (options?.skip) { + results = results.slice(options.skip); + } + if (options?.take) { + results = results.slice(0, options.take); + } + return results; + } + + async count(options?: { cursor?: string }): Promise { + let results = [...this.users.values()].filter((u) => !u.deletedAt); + if (options?.cursor) { + const cursorIndex = results.findIndex((u) => u.id === options.cursor); + if (cursorIndex >= 0) { + results = results.slice(0, cursorIndex); + } + } + return results.length; } async save(user: Partial) { @@ -89,8 +125,7 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { return ( [...this.challenges.values()].find( (challenge) => - challenge.stellarAddress === stellarAddress && - challenge.nonceHash === nonceHash, + challenge.stellarAddress === stellarAddress && challenge.nonceHash === nonceHash ) ?? null ); } @@ -105,6 +140,28 @@ class InMemoryChallengeRepository implements ChallengeRepositoryContract { challenge.consumedAt = consumedAt; return true; } + + async deleteExpired(before: Date): Promise { + let count = 0; + for (const [id, challenge] of this.challenges.entries()) { + if (challenge.expiresAt < before || (challenge.consumedAt && challenge.consumedAt < before)) { + this.challenges.delete(id); + count++; + } + } + return count; + } + + async countByStatus(status: "active" | "consumed" | "expired"): Promise { + const now = new Date(); + let count = 0; + for (const challenge of this.challenges.values()) { + if (status === "active" && !challenge.consumedAt && challenge.expiresAt > now) count++; + if (status === "consumed" && challenge.consumedAt) count++; + if (status === "expired" && !challenge.consumedAt && challenge.expiresAt <= now) count++; + } + return count; + } } // ── Test helpers ────────────────────────────────────────────────────────────── @@ -140,6 +197,15 @@ describe("JWT authentication validation", () => { jest.clearAllMocks(); }); + const forgedToken = jwt.sign( + { + sub: "GFORGED_STELLAR_ADDRESS", + stellarAddress: "GFORGED_STELLAR_ADDRESS", + userId: crypto.randomUUID(), + }, + "invalid-secret-key", + { expiresIn: "15m" } + ); it("rejects GET /api/v1/auth/me when the JWT is signed with an invalid secret key", async () => { try { const app = createTestApp(); @@ -180,7 +246,7 @@ describe("JWT authentication validation", () => { userId: crypto.randomUUID(), }, VALID_JWT_SECRET, - { expiresIn: "-5m" }, + { expiresIn: "-5m" } ); const response = await request(app) diff --git a/tests/integration/concurrent-investment.integration.test.ts b/tests/integration/concurrent-investment.integration.test.ts index 0afcd83..3d98d5e 100644 --- a/tests/integration/concurrent-investment.integration.test.ts +++ b/tests/integration/concurrent-investment.integration.test.ts @@ -31,22 +31,22 @@ function createSerializedFakeDataSource(invoice: Invoice) { }, find: async ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => { if (entity === Investment) { const clauses = Array.isArray(options.where) ? options.where : [options.where]; return [...investments.values()].filter((inv) => clauses.some((clause) => Object.entries(clause).every( - ([k, v]) => (inv as unknown as Record)[k] === v, - ), - ), + ([k, v]) => (inv as unknown as Record)[k] === v + ) + ) ); } return []; }, create: (_entity: unknown, data: Partial) => - ({ id: crypto.randomUUID(), status: InvestmentStatus.PENDING, ...data } as Investment), + ({ id: crypto.randomUUID(), status: InvestmentStatus.PENDING, ...data }) as Investment, save: async (entity: unknown, data: Investment | Invoice) => { if (entity === Investment) investments.set((data as Investment).id, data as Investment); else if (entity === Invoice) invoices.set((data as Invoice).id, data as Invoice); @@ -153,7 +153,7 @@ describe("Concurrent investment: total committed amount must not exceed invoice // Total committed = 700 (seeded) + 200 (one success) = 900; well under 1000 const totalCommitted = [...investments.values()].reduce( (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), - new Decimal(0), + new Decimal(0) ); expect(totalCommitted.toFixed(4)).toBe("900.0000"); }); @@ -221,7 +221,7 @@ describe("Concurrent investment: total committed amount must not exceed invoice const totalCommitted = [...investments.values()].reduce( (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), - new Decimal(0), + new Decimal(0) ); expect(totalCommitted.toFixed(4)).toBe("500.0000"); @@ -251,8 +251,8 @@ describe("Concurrent investment: total committed amount must not exceed invoice investorId: investor.id, investmentAmount: "400.0000", investorWallet: investor.wallet, - }), - ), + }) + ) ); const fulfilled = results.filter((r) => r.status === "fulfilled"); @@ -269,7 +269,7 @@ describe("Concurrent investment: total committed amount must not exceed invoice expect(investments.size).toBe(2); const totalCommitted = [...investments.values()].reduce( (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), - new Decimal(0), + new Decimal(0) ); expect(totalCommitted.toFixed(4)).toBe("800.0000"); expect(totalCommitted.lte(new Decimal("1000.0000"))).toBe(true); diff --git a/tests/integration/expired-invoice.integration.test.ts b/tests/integration/expired-invoice.integration.test.ts index 0cf8cab..a717bf8 100644 --- a/tests/integration/expired-invoice.integration.test.ts +++ b/tests/integration/expired-invoice.integration.test.ts @@ -25,7 +25,7 @@ function createFakeDataSource(invoice: Invoice) { }, find: async () => [] as Investment[], create: (_entity: unknown, data: Partial) => - ({ id: crypto.randomUUID(), status: InvestmentStatus.PENDING, ...data } as Investment), + ({ id: crypto.randomUUID(), status: InvestmentStatus.PENDING, ...data }) as Investment, save: async (_entity: unknown, data: Investment | Invoice) => { if ((data as Investment).investmentAmount !== undefined) { investments.set((data as Investment).id, data as Investment); @@ -79,7 +79,7 @@ describe("Expired invoice rejects new investment commitments (issue #109)", () = investorId: crypto.randomUUID(), investmentAmount: "100.0000", investorWallet: INVESTOR_WALLET, - }), + }) ).rejects.toMatchObject({ code: "invoice_expired", statusCode: 422, @@ -97,7 +97,7 @@ describe("Expired invoice rejects new investment commitments (issue #109)", () = investorId: crypto.randomUUID(), investmentAmount: "100.0000", investorWallet: INVESTOR_WALLET, - }), + }) ).rejects.toBeInstanceOf(ServiceError); expect(investments.size).toBe(0); @@ -135,7 +135,7 @@ describe("Expired invoice rejects new investment commitments (issue #109)", () = investorId: crypto.randomUUID(), investmentAmount: "100.0000", investorWallet: INVESTOR_WALLET, - }), + }) ).resolves.toMatchObject({ status: InvestmentStatus.PENDING }); expect(investments.size).toBe(1); diff --git a/tests/integration/fractional-investment.integration.test.ts b/tests/integration/fractional-investment.integration.test.ts index 26fd98c..318bfce 100644 --- a/tests/integration/fractional-investment.integration.test.ts +++ b/tests/integration/fractional-investment.integration.test.ts @@ -11,14 +11,17 @@ import { InvoiceStatus, InvestmentStatus } from "../../src/types/enums"; * InvestmentService funding logic end to end without a live database. */ type FakeManager = { - createQueryBuilder: (entity: unknown, alias: string) => { + createQueryBuilder: ( + entity: unknown, + alias: string + ) => { setLock: () => unknown; where: (clause: string, params: { id: string }) => unknown; getOne: () => Promise; }; find: ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => Promise; create: (entity: unknown, data: Partial) => Investment | Partial; save: (entity: unknown, data: Investment | Invoice) => Promise; @@ -37,22 +40,22 @@ function createFakeDataSource(invoice: Invoice) { targetId = params.id; return builder; }, - getOne: async () => (targetId ? invoices.get(targetId) ?? null : null), + getOne: async () => (targetId ? (invoices.get(targetId) ?? null) : null), }; return builder; }, find: async ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => { if (entity === Investment) { const whereClauses = Array.isArray(options.where) ? options.where : [options.where]; return [...investments.values()].filter((investment) => whereClauses.some((clause) => Object.entries(clause).every( - ([key, value]) => (investment as unknown as Record)[key] === value, - ), - ), + ([key, value]) => (investment as unknown as Record)[key] === value + ) + ) ); } return []; @@ -110,9 +113,18 @@ describe("Fractional investment integration: splitting funded amount across mult const { dataSource, invoices, investments } = createFakeDataSource(invoice); const investmentService = new InvestmentService(dataSource); - const investorA = { id: crypto.randomUUID(), wallet: "GINVESTORA1234567890ABCDEFGHIJKLMNOPQRSTUVWXY" }; - const investorB = { id: crypto.randomUUID(), wallet: "GINVESTORB1234567890ABCDEFGHIJKLMNOPQRSTUVWXY" }; - const investorC = { id: crypto.randomUUID(), wallet: "GINVESTORC1234567890ABCDEFGHIJKLMNOPQRSTUVWXY" }; + const investorA = { + id: crypto.randomUUID(), + wallet: "GINVESTORA1234567890ABCDEFGHIJKLMNOPQRSTUVWXY", + }; + const investorB = { + id: crypto.randomUUID(), + wallet: "GINVESTORB1234567890ABCDEFGHIJKLMNOPQRSTUVWXY", + }; + const investorC = { + id: crypto.randomUUID(), + wallet: "GINVESTORC1234567890ABCDEFGHIJKLMNOPQRSTUVWXY", + }; const investmentA = await investmentService.createInvestment({ invoiceId: invoice.id, @@ -145,12 +157,12 @@ describe("Fractional investment integration: splitting funded amount across mult const totalFunded = allInvestments.reduce( (sum, investment) => sum.plus(new Decimal(investment.investmentAmount)), - new Decimal(0), + new Decimal(0) ); expect(totalFunded.toFixed(4)).toBe("10000.0000"); const sharePercentages = allInvestments.map((investment) => - new Decimal(investment.investmentAmount).dividedBy(totalFunded).times(100), + new Decimal(investment.investmentAmount).dividedBy(totalFunded).times(100) ); expect(sharePercentages[0].toFixed(2)).toBe("40.00"); diff --git a/tests/integration/investment-overflow.integration.test.ts b/tests/integration/investment-overflow.integration.test.ts index 176ce50..c6412e1 100644 --- a/tests/integration/investment-overflow.integration.test.ts +++ b/tests/integration/investment-overflow.integration.test.ts @@ -12,14 +12,17 @@ import { ServiceError } from "../../src/utils/service-error"; * InvestmentService funding logic end to end without a live database. */ type FakeManager = { - createQueryBuilder: (entity: unknown, alias: string) => { + createQueryBuilder: ( + entity: unknown, + alias: string + ) => { setLock: () => unknown; where: (clause: string, params: { id: string }) => unknown; getOne: () => Promise; }; find: ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => Promise; create: (entity: unknown, data: Partial) => Investment | Partial; save: (entity: unknown, data: Investment | Invoice) => Promise; @@ -38,22 +41,22 @@ function createFakeDataSource(invoice: Invoice) { targetId = params.id; return builder; }, - getOne: async () => (targetId ? invoices.get(targetId) ?? null : null), + getOne: async () => (targetId ? (invoices.get(targetId) ?? null) : null), }; return builder; }, find: async ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => { if (entity === Investment) { const whereClauses = Array.isArray(options.where) ? options.where : [options.where]; return [...investments.values()].filter((investment) => whereClauses.some((clause) => Object.entries(clause).every( - ([key, value]) => (investment as unknown as Record)[key] === value, - ), - ), + ([key, value]) => (investment as unknown as Record)[key] === value + ) + ) ); } return []; @@ -111,8 +114,14 @@ describe("Investment overflow integration: rejecting commitments exceeding invoi const { dataSource, invoices, investments } = createFakeDataSource(invoice); const investmentService = new InvestmentService(dataSource); - const investorA = { id: crypto.randomUUID(), wallet: "GINVESTORA1234567890ABCDEFGHIJKLMNOPQRSTUVWXY" }; - const investorB = { id: crypto.randomUUID(), wallet: "GINVESTORB1234567890ABCDEFGHIJKLMNOPQRSTUVWXY" }; + const investorA = { + id: crypto.randomUUID(), + wallet: "GINVESTORA1234567890ABCDEFGHIJKLMNOPQRSTUVWXY", + }; + const investorB = { + id: crypto.randomUUID(), + wallet: "GINVESTORB1234567890ABCDEFGHIJKLMNOPQRSTUVWXY", + }; await investmentService.createInvestment({ invoiceId: invoice.id, @@ -128,7 +137,7 @@ describe("Investment overflow integration: rejecting commitments exceeding invoi investorId: investorB.id, investmentAmount: "2000.0000", investorWallet: investorB.wallet, - }), + }) ).rejects.toMatchObject({ code: "INSUFFICIENT_CAPACITY", statusCode: 400, @@ -138,7 +147,7 @@ describe("Investment overflow integration: rejecting commitments exceeding invoi expect(investments.size).toBe(1); const totalFundedAfterRejection = [...investments.values()].reduce( (sum, investment) => sum.plus(new Decimal(investment.investmentAmount)), - new Decimal(0), + new Decimal(0) ); expect(totalFundedAfterRejection.toFixed(4)).toBe("4000.0000"); expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.PUBLISHED); diff --git a/tests/integration/investor-dashboard.test.ts b/tests/integration/investor-dashboard.test.ts index c407472..bee8380 100644 --- a/tests/integration/investor-dashboard.test.ts +++ b/tests/integration/investor-dashboard.test.ts @@ -43,8 +43,18 @@ describe("Investor dashboard aggregate", () => { it("aggregates active, settled, and failed positions without leaking other investors", async () => { const investments = [ - seedInvestment({ id: "inv-1", investorId: walletAId, investmentAmount: "3000.0000", status: InvestmentStatus.PENDING }), - seedInvestment({ id: "inv-2", investorId: walletAId, investmentAmount: "2000.0000", status: InvestmentStatus.CONFIRMED }), + seedInvestment({ + id: "inv-1", + investorId: walletAId, + investmentAmount: "3000.0000", + status: InvestmentStatus.PENDING, + }), + seedInvestment({ + id: "inv-2", + investorId: walletAId, + investmentAmount: "2000.0000", + status: InvestmentStatus.CONFIRMED, + }), seedInvestment({ id: "inv-3", investorId: walletAId, @@ -115,7 +125,11 @@ describe("Investor dashboard aggregate", () => { it("excludes cancelled investments from the active count", async () => { mockRepository.find.mockResolvedValue([ - seedInvestment({ id: "inv-1", investmentAmount: "800.0000", status: InvestmentStatus.CANCELLED }), + seedInvestment({ + id: "inv-1", + investmentAmount: "800.0000", + status: InvestmentStatus.CANCELLED, + }), ]); const dashboard = await investmentService.getInvestorDashboard(walletAId); diff --git a/tests/integration/invoice-detail-commitments.integration.test.ts b/tests/integration/invoice-detail-commitments.integration.test.ts index cf1243c..7569003 100644 --- a/tests/integration/invoice-detail-commitments.integration.test.ts +++ b/tests/integration/invoice-detail-commitments.integration.test.ts @@ -17,9 +17,7 @@ function createFakeInvoiceService() { findOne: async ({ where: { id }, relations }) => { const invoice = invoices.get(id) ?? null; if (invoice && relations?.includes("investments")) { - const relatedInvestments = [...investments.values()].filter( - (inv) => inv.invoiceId === id, - ); + const relatedInvestments = [...investments.values()].filter((inv) => inv.invoiceId === id); invoice.investments = relatedInvestments; } return invoice; @@ -70,11 +68,7 @@ function createInvoice(overrides: Partial = {}): Invoice { } as Invoice; } -function createInvestment( - invoiceId: string, - investorWallet: string, - amount: string, -): Investment { +function createInvestment(invoiceId: string, investorWallet: string, amount: string): Investment { return { id: crypto.randomUUID(), invoiceId, @@ -146,7 +140,7 @@ describe("Invoice detail endpoint: investor commitments with share percentages", // Assert sum of all share percentages is exactly 100% const sumPercent = commitmentEntries.reduce( (sum, entry) => sum + parseFloat(entry.share_percent), - 0, + 0 ); expect(sumPercent).toBe(100); @@ -175,8 +169,7 @@ describe("Invoice detail endpoint: investor commitments with share percentages", investments.set(inv.id, inv); // Compute truncated wallet as the endpoint would - const truncated = - wallet.length >= 8 ? `${wallet.slice(0, 4)}…${wallet.slice(-4)}` : wallet; + const truncated = wallet.length >= 8 ? `${wallet.slice(0, 4)}…${wallet.slice(-4)}` : wallet; expect(truncated).toBe("GA5X…Z7W7"); }); -}); \ No newline at end of file +}); diff --git a/tests/integration/invoice-draft-update.integration.test.ts b/tests/integration/invoice-draft-update.integration.test.ts index 6d2798e..f8143d0 100644 --- a/tests/integration/invoice-draft-update.integration.test.ts +++ b/tests/integration/invoice-draft-update.integration.test.ts @@ -18,8 +18,7 @@ class InMemoryInvoiceRepository implements InvoiceRepositoryContract { async findOneBy(options: { id?: string; invoiceNumber?: string }) { for (const invoice of this.invoices.values()) { if (options.id && invoice.id === options.id) return invoice; - if (options.invoiceNumber && invoice.invoiceNumber === options.invoiceNumber) - return invoice; + if (options.invoiceNumber && invoice.invoiceNumber === options.invoiceNumber) return invoice; } return null; } @@ -33,7 +32,7 @@ class InMemoryInvoiceRepository implements InvoiceRepositoryContract { return [...this.invoices.values()].filter( (inv) => inv.sellerId === options.where.sellerId && - (options.where.status == null || inv.status === options.where.status), + (options.where.status == null || inv.status === options.where.status) ); } @@ -63,10 +62,7 @@ function noopIpfsService(): IPFSService { } as unknown as IPFSService; } -function seedDraftInvoice( - repo: InMemoryInvoiceRepository, - sellerId: string, -): Invoice { +function seedDraftInvoice(repo: InMemoryInvoiceRepository, sellerId: string): Invoice { const now = new Date(); const invoice: Invoice = { id: crypto.randomUUID(), @@ -152,7 +148,7 @@ describe("Invoice draft update integration (issue #112)", () => { invoiceId: invoice.id, sellerId: sellerA, customerName: "Should Not Update", - }), + }) ).rejects.toMatchObject({ code: "invalid_invoice_status", statusCode: 400, @@ -167,7 +163,7 @@ describe("Invoice draft update integration (issue #112)", () => { invoiceId: invoice.id, sellerId: sellerB, customerName: "Unauthorized Update", - }), + }) ).rejects.toMatchObject({ code: "unauthorized_invoice_access", statusCode: 403, diff --git a/tests/integration/invoice-publish-kyc-gate.integration.test.ts b/tests/integration/invoice-publish-kyc-gate.integration.test.ts index 46c626b..a5d2d53 100644 --- a/tests/integration/invoice-publish-kyc-gate.integration.test.ts +++ b/tests/integration/invoice-publish-kyc-gate.integration.test.ts @@ -33,8 +33,7 @@ class InMemoryInvoiceRepository implements InvoiceRepositoryContract { async findOneBy(options: { id?: string; invoiceNumber?: string }) { for (const invoice of this.invoices.values()) { if (options.id && invoice.id === options.id) return invoice; - if (options.invoiceNumber && invoice.invoiceNumber === options.invoiceNumber) - return invoice; + if (options.invoiceNumber && invoice.invoiceNumber === options.invoiceNumber) return invoice; } return null; } @@ -48,7 +47,7 @@ class InMemoryInvoiceRepository implements InvoiceRepositoryContract { return [...this.invoices.values()].filter( (inv) => inv.sellerId === options.where.sellerId && - (options.where.status == null || inv.status === options.where.status), + (options.where.status == null || inv.status === options.where.status) ); } @@ -96,10 +95,7 @@ function makeSeller(kycStatus: KYCStatus | null): User { * attached) so that a rejection in these tests can only be attributed to * the KYC gate, not incidental publish-validation failures. */ -function seedPublishableInvoice( - repo: InMemoryInvoiceRepository, - seller: User, -): Invoice { +function seedPublishableInvoice(repo: InMemoryInvoiceRepository, seller: User): Invoice { const now = new Date(); const invoice: Invoice = { id: crypto.randomUUID(), @@ -141,7 +137,7 @@ describe("Invoice publish: approved-seller KYC gate (issue #217)", () => { const invoice = seedPublishableInvoice(repo, seller); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }) ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403, @@ -153,7 +149,7 @@ describe("Invoice publish: approved-seller KYC gate (issue #217)", () => { const invoice = seedPublishableInvoice(repo, seller); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }) ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403, @@ -165,7 +161,7 @@ describe("Invoice publish: approved-seller KYC gate (issue #217)", () => { const invoice = seedPublishableInvoice(repo, seller); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }) ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403, @@ -177,7 +173,7 @@ describe("Invoice publish: approved-seller KYC gate (issue #217)", () => { const invoice = seedPublishableInvoice(repo, seller); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }) ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403, @@ -192,7 +188,7 @@ describe("Invoice publish: approved-seller KYC gate (issue #217)", () => { const invoice = seedPublishableInvoice(repo, seller); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: seller.id }) ).rejects.toMatchObject({ code: "kyc_approval_required" }); const persisted = await repo.findOne({ where: { id: invoice.id } }); @@ -221,7 +217,7 @@ describe("Invoice publish: approved-seller KYC gate (issue #217)", () => { const invoice = seedPublishableInvoice(repo, owner); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: impostor.id }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: impostor.id }) ).rejects.toMatchObject({ code: "unauthorized_invoice_access", statusCode: 403, diff --git a/tests/integration/invoice-search.integration.test.ts b/tests/integration/invoice-search.integration.test.ts index b1df871..df25a12 100644 --- a/tests/integration/invoice-search.integration.test.ts +++ b/tests/integration/invoice-search.integration.test.ts @@ -1,5 +1,8 @@ import crypto from "crypto"; -import { MarketplaceService, MarketplaceRepositoryContract } from "../../src/services/marketplace.service"; +import { + MarketplaceService, + MarketplaceRepositoryContract, +} from "../../src/services/marketplace.service"; import { Invoice } from "../../src/models/Invoice.model"; import { InvoiceStatus } from "../../src/types/enums"; @@ -10,14 +13,13 @@ import { InvoiceStatus } from "../../src/types/enums"; function createFakeMarketplaceRepository(invoices: Invoice[]): MarketplaceRepositoryContract { return { async findPublishedInvoices(filters) { - const statuses = filters.status && filters.status.length > 0 ? filters.status : [InvoiceStatus.PUBLISHED]; + const statuses = + filters.status && filters.status.length > 0 ? filters.status : [InvoiceStatus.PUBLISHED]; let matched = invoices.filter((invoice) => statuses.includes(invoice.status)); if (filters.search) { const term = filters.search.toLowerCase(); - matched = matched.filter((inv) => - inv.customerName.toLowerCase().includes(term), - ); + matched = matched.filter((inv) => inv.customerName.toLowerCase().includes(term)); } return { invoices: matched, total: matched.length }; diff --git a/tests/integration/ipfs-upload.test.ts b/tests/integration/ipfs-upload.test.ts index 9677efd..4fd2a24 100644 --- a/tests/integration/ipfs-upload.test.ts +++ b/tests/integration/ipfs-upload.test.ts @@ -2,7 +2,9 @@ import { IPFSService } from "../../src/services/ipfs.service"; import { ServiceError } from "../../src/utils/service-error"; import { logger } from "../../src/observability/logger"; -function createMockFetch(responses: Array<{ ok: boolean; status: number; statusText: string; body: unknown }>) { +function createMockFetch( + responses: Array<{ ok: boolean; status: number; statusText: string; body: unknown }> +) { let callCount = 0; return jest.fn().mockImplementation(async () => { const response = responses[callCount] ?? responses[responses.length - 1]; @@ -36,8 +38,22 @@ describe("IPFS upload integration – retry backoff & error handling", () => { describe("429 rate limit followed by successful retry", () => { it("retries after 429 and succeeds on second attempt", async () => { const mockFetch = createMockFetch([ - { ok: false, status: 429, statusText: "Too Many Requests", body: { error: "Rate limit exceeded" } }, - { ok: true, status: 200, statusText: "OK", body: { IpfsHash: "QmRetrySuccess", PinSize: 1024, Timestamp: "2024-01-01T00:00:00.000Z" } }, + { + ok: false, + status: 429, + statusText: "Too Many Requests", + body: { error: "Rate limit exceeded" }, + }, + { + ok: true, + status: 200, + statusText: "OK", + body: { + IpfsHash: "QmRetrySuccess", + PinSize: 1024, + Timestamp: "2024-01-01T00:00:00.000Z", + }, + }, ]); const service = new IPFSService({ @@ -46,11 +62,23 @@ describe("IPFS upload integration – retry backoff & error handling", () => { fetchImplementation: mockFetch, }); - const firstAttempt = service.uploadFile(validBuffer, validFilename, validMimeType, "inv-1", 1); + const firstAttempt = service.uploadFile( + validBuffer, + validFilename, + validMimeType, + "inv-1", + 1 + ); await expect(firstAttempt).rejects.toThrow(ServiceError); await expect(firstAttempt).rejects.toMatchObject({ code: "ipfs_upload_failed" }); - const secondAttempt = service.uploadFile(validBuffer, validFilename, validMimeType, "inv-1", 2); + const secondAttempt = service.uploadFile( + validBuffer, + validFilename, + validMimeType, + "inv-1", + 2 + ); const result = await secondAttempt; expect(result.hash).toBe("QmRetrySuccess"); expect(mockFetch).toHaveBeenCalledTimes(2); @@ -82,7 +110,11 @@ describe("IPFS upload integration – retry backoff & error handling", () => { ok: true, status: 200, statusText: "OK", - json: async () => ({ IpfsHash: "QmBackoff", PinSize: 512, Timestamp: "2024-01-01T00:00:00.000Z" }), + json: async () => ({ + IpfsHash: "QmBackoff", + PinSize: 512, + Timestamp: "2024-01-01T00:00:00.000Z", + }), text: async () => "", }; }); @@ -117,11 +149,11 @@ describe("IPFS upload integration – retry backoff & error handling", () => { }); await expect( - service.uploadFile(oversizedBuffer, validFilename, validMimeType), + service.uploadFile(oversizedBuffer, validFilename, validMimeType) ).rejects.toThrow(ServiceError); await expect( - service.uploadFile(oversizedBuffer, validFilename, validMimeType), + service.uploadFile(oversizedBuffer, validFilename, validMimeType) ).rejects.toMatchObject({ code: "file_too_large", statusCode: 400 }); expect(mockFetch).not.toHaveBeenCalled(); @@ -137,7 +169,7 @@ describe("IPFS upload integration – retry backoff & error handling", () => { }); await expect( - service.uploadFile(validBuffer, "test.exe", "application/x-executable"), + service.uploadFile(validBuffer, "test.exe", "application/x-executable") ).rejects.toMatchObject({ code: "invalid_file_type", statusCode: 400 }); expect(mockFetch).not.toHaveBeenCalled(); @@ -157,13 +189,18 @@ describe("IPFS upload integration – retry backoff & error handling", () => { mockFetch.mockRejectedValue(new Error("ECONNREFUSED")); await expect( - service.uploadFile(validBuffer, validFilename, validMimeType), + service.uploadFile(validBuffer, validFilename, validMimeType) ).rejects.toMatchObject({ code: "ipfs_upload_error", statusCode: 500 }); }); it("reports non-2xx Pinata responses as ipfs_upload_failed", async () => { const mockFetch = createMockFetch([ - { ok: false, status: 500, statusText: "Internal Server Error", body: { error: "Pinata unavailable" } }, + { + ok: false, + status: 500, + statusText: "Internal Server Error", + body: { error: "Pinata unavailable" }, + }, ]); const service = new IPFSService({ @@ -173,13 +210,18 @@ describe("IPFS upload integration – retry backoff & error handling", () => { }); await expect( - service.uploadFile(validBuffer, validFilename, validMimeType), + service.uploadFile(validBuffer, validFilename, validMimeType) ).rejects.toMatchObject({ code: "ipfs_upload_failed", statusCode: 502 }); }); it("includes error details in thrown ServiceError", async () => { const mockFetch = createMockFetch([ - { ok: false, status: 402, statusText: "Payment Required", body: { error: "Subscription expired" } }, + { + ok: false, + status: 402, + statusText: "Payment Required", + body: { error: "Subscription expired" }, + }, ]); const service = new IPFSService({ @@ -206,7 +248,11 @@ describe("IPFS upload integration – retry backoff & error handling", () => { ok: true, status: 200, statusText: "OK", - body: { IpfsHash: "QmFinalHash999", PinSize: 2048, Timestamp: "2024-06-15T12:00:00.000Z" }, + body: { + IpfsHash: "QmFinalHash999", + PinSize: 2048, + Timestamp: "2024-06-15T12:00:00.000Z", + }, }, ]); @@ -216,7 +262,13 @@ describe("IPFS upload integration – retry backoff & error handling", () => { fetchImplementation: mockFetch, }); - const result = await service.uploadFile(validBuffer, validFilename, validMimeType, "inv-1", 1); + const result = await service.uploadFile( + validBuffer, + validFilename, + validMimeType, + "inv-1", + 1 + ); expect(result).toEqual({ hash: "QmFinalHash999", @@ -227,7 +279,12 @@ describe("IPFS upload integration – retry backoff & error handling", () => { it("sends correct Authorization header to Pinata", async () => { const mockFetch = createMockFetch([ - { ok: true, status: 200, statusText: "OK", body: { IpfsHash: "QmAuth", PinSize: 100, Timestamp: "2024-01-01T00:00:00.000Z" } }, + { + ok: true, + status: 200, + statusText: "OK", + body: { IpfsHash: "QmAuth", PinSize: 100, Timestamp: "2024-01-01T00:00:00.000Z" }, + }, ]); const service = new IPFSService({ @@ -243,7 +300,7 @@ describe("IPFS upload integration – retry backoff & error handling", () => { expect.objectContaining({ method: "POST", headers: { Authorization: "Bearer test-jwt-token" }, - }), + }) ); }); }); diff --git a/tests/integration/kyc-dev-approval-path.integration.test.ts b/tests/integration/kyc-dev-approval-path.integration.test.ts index 2620e40..ee97785 100644 --- a/tests/integration/kyc-dev-approval-path.integration.test.ts +++ b/tests/integration/kyc-dev-approval-path.integration.test.ts @@ -132,4 +132,4 @@ describe("KYC dev approval path", () => { expect(result.statusCode).toBe(401); expect(result.errorMessage).toContain("Authentication required"); }); -}); \ No newline at end of file +}); diff --git a/tests/integration/marketplace-cursor-pagination.integration.test.ts b/tests/integration/marketplace-cursor-pagination.integration.test.ts index a2e2010..9245e14 100644 --- a/tests/integration/marketplace-cursor-pagination.integration.test.ts +++ b/tests/integration/marketplace-cursor-pagination.integration.test.ts @@ -1,7 +1,10 @@ import "reflect-metadata"; import crypto from "crypto"; import { DataSource, getMetadataArgsStorage } from "typeorm"; -import { MarketplaceService, createMarketplaceService } from "../../src/services/marketplace.service"; +import { + MarketplaceService, + createMarketplaceService, +} from "../../src/services/marketplace.service"; import { Invoice } from "../../src/models/Invoice.model"; import { User } from "../../src/models/User.model"; import { Investment } from "../../src/models/Investment.model"; @@ -51,7 +54,15 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { database: ":memory:", synchronize: true, logging: false, - entities: [User, Invoice, Investment, Transaction, KYCVerification, Notification, AuthChallenge], + entities: [ + User, + Invoice, + Investment, + Transaction, + KYCVerification, + Notification, + AuthChallenge, + ], }); await dataSource.initialize(); @@ -63,7 +74,7 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { email: "seller-cursor@test.com", userType: UserType.SELLER, kycStatus: KYCStatus.APPROVED, - } as any), + } as any) )) as unknown as User; sellerId = seller.id; @@ -99,15 +110,12 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { status: InvoiceStatus.PUBLISHED, smartContractId: null, ...overrides, - } as any), + } as any) )) as unknown as Invoice; } /** Seeds `count` listings that all share the same primary sort value. */ - async function seedTiedListings( - count: number, - shared: Partial, - ): Promise { + async function seedTiedListings(count: number, shared: Partial): Promise { const ids: string[] = []; for (let i = 0; i < count; i += 1) { const invoice = await seedInvoice(shared); @@ -118,7 +126,7 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { async function collectAllPages( sortField: "amount" | "created_at", - limit: number, + limit: number ): Promise { const ordered: string[] = []; let cursor: string | null = null; @@ -132,7 +140,7 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { order: "DESC", limit, cursor, - }, + } ); page.data.forEach((invoice) => ordered.push(invoice.id)); pages += 1; @@ -147,7 +155,10 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { } it("pages over equal face values with deterministic id tie-ins (no repeats, no skips)", async () => { - const expected = await seedTiedListings(7, { amount: "5000.0000", status: InvoiceStatus.PUBLISHED }); + const expected = await seedTiedListings(7, { + amount: "5000.0000", + status: InvoiceStatus.PUBLISHED, + }); const idAsc = [...expected].sort((a, b) => a.localeCompare(b)); const ordered = await collectAllPages("amount", 2); @@ -169,7 +180,7 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { const page = await service.getPublishedInvoicesByCursor( { status: [InvoiceStatus.PUBLISHED] }, - { sortField: "amount", order: "DESC", limit: 10 }, + { sortField: "amount", order: "DESC", limit: 10 } ); const amounts = page.data.map((invoice) => Number(invoice.amount)); @@ -193,10 +204,7 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { // Force the created_at column to an identical value so the primary sort is // genuinely tied (CreateDateColumn otherwise timestamps each insert). - await dataSource.query( - `UPDATE invoices SET created_at = ?`, - [fixedCreatedAt.toISOString()], - ); + await dataSource.query(`UPDATE invoices SET created_at = ?`, [fixedCreatedAt.toISOString()]); const idAsc = [...ids].sort((a, b) => a.localeCompare(b)); const ordered = await collectAllPages("created_at", 3); @@ -210,20 +218,24 @@ describe("Marketplace cursor pagination stable ordering (issue #226)", () => { await expect( service.getPublishedInvoicesByCursor( { status: [InvoiceStatus.PUBLISHED] }, - { sortField: "amount", order: "DESC", limit: 10, cursor: "not-valid-cursor!!" }, - ), + { sortField: "amount", order: "DESC", limit: 10, cursor: "not-valid-cursor!!" } + ) ).rejects.toMatchObject({ code: "invalid_cursor", statusCode: 400 }); }); it("returns the expected client error when a cursor is encoded for a different field", async () => { const { encodeQueryCursor } = await import("../../src/utils/query-pagination.utils"); - const wrongFieldCursor = encodeQueryCursor("invoice.dueDate", "2026-01-31T00:00:00.000Z", "some-id"); + const wrongFieldCursor = encodeQueryCursor( + "invoice.dueDate", + "2026-01-31T00:00:00.000Z", + "some-id" + ); await expect( service.getPublishedInvoicesByCursor( { status: [InvoiceStatus.PUBLISHED] }, - { sortField: "amount", order: "DESC", limit: 10, cursor: wrongFieldCursor }, - ), + { sortField: "amount", order: "DESC", limit: 10, cursor: wrongFieldCursor } + ) ).rejects.toMatchObject({ code: "invalid_cursor", statusCode: 400 }); }); }); diff --git a/tests/integration/marketplace-listing.integration.test.ts b/tests/integration/marketplace-listing.integration.test.ts index a960050..c57f308 100644 --- a/tests/integration/marketplace-listing.integration.test.ts +++ b/tests/integration/marketplace-listing.integration.test.ts @@ -1,5 +1,8 @@ import crypto from "crypto"; -import { MarketplaceService, MarketplaceRepositoryContract } from "../../src/services/marketplace.service"; +import { + MarketplaceService, + MarketplaceRepositoryContract, +} from "../../src/services/marketplace.service"; import { Invoice } from "../../src/models/Invoice.model"; import { InvoiceStatus } from "../../src/types/enums"; @@ -12,7 +15,8 @@ import { InvoiceStatus } from "../../src/types/enums"; function createFakeMarketplaceRepository(invoices: Invoice[]): MarketplaceRepositoryContract { return { async findPublishedInvoices(filters) { - const statuses = filters.status && filters.status.length > 0 ? filters.status : [InvoiceStatus.PUBLISHED]; + const statuses = + filters.status && filters.status.length > 0 ? filters.status : [InvoiceStatus.PUBLISHED]; const matched = invoices.filter((invoice) => statuses.includes(invoice.status)); return { invoices: matched, total: matched.length }; }, @@ -50,7 +54,13 @@ describe("Marketplace listing integration: filtering invoices by status", () => const fundedInvoice = createInvoice({ status: InvoiceStatus.FUNDED }); const settledInvoice = createInvoice({ status: InvoiceStatus.SETTLED }); - const allInvoices = [draftInvoice, publishedInvoiceA, publishedInvoiceB, fundedInvoice, settledInvoice]; + const allInvoices = [ + draftInvoice, + publishedInvoiceA, + publishedInvoiceB, + fundedInvoice, + settledInvoice, + ]; function createService(): MarketplaceService { return new MarketplaceService({ @@ -65,7 +75,7 @@ describe("Marketplace listing integration: filtering invoices by status", () => expect(result.data).toHaveLength(2); expect(result.data.map((invoice) => invoice.id).sort()).toEqual( - [publishedInvoiceA.id, publishedInvoiceB.id].sort(), + [publishedInvoiceA.id, publishedInvoiceB.id].sort() ); expect(result.data.every((invoice) => invoice.status === InvoiceStatus.PUBLISHED)).toBe(true); }); @@ -73,7 +83,9 @@ describe("Marketplace listing integration: filtering invoices by status", () => it("returns only funded invoices when filtered by status=funded", async () => { const marketplaceService = createService(); - const result = await marketplaceService.getPublishedInvoices({ status: [InvoiceStatus.FUNDED] }); + const result = await marketplaceService.getPublishedInvoices({ + status: [InvoiceStatus.FUNDED], + }); expect(result.data).toHaveLength(1); expect(result.data[0].id).toBe(fundedInvoice.id); @@ -82,7 +94,9 @@ describe("Marketplace listing integration: filtering invoices by status", () => it("returns only settled invoices when filtered by status=settled", async () => { const marketplaceService = createService(); - const result = await marketplaceService.getPublishedInvoices({ status: [InvoiceStatus.SETTLED] }); + const result = await marketplaceService.getPublishedInvoices({ + status: [InvoiceStatus.SETTLED], + }); expect(result.data).toHaveLength(1); expect(result.data[0].id).toBe(settledInvoice.id); diff --git a/tests/integration/marketplace-sorting.test.ts b/tests/integration/marketplace-sorting.test.ts index f410fa1..7b99bb0 100644 --- a/tests/integration/marketplace-sorting.test.ts +++ b/tests/integration/marketplace-sorting.test.ts @@ -1,5 +1,8 @@ import crypto from "crypto"; -import { MarketplaceService, MarketplaceRepositoryContract } from "../../src/services/marketplace.service"; +import { + MarketplaceService, + MarketplaceRepositoryContract, +} from "../../src/services/marketplace.service"; import { Invoice } from "../../src/models/Invoice.model"; import { InvoiceStatus } from "../../src/types/enums"; @@ -8,160 +11,164 @@ import { InvoiceStatus } from "../../src/types/enums"; * Supports sorting by amount (face value) and due_date. */ function createFakeMarketplaceRepository(invoices: Invoice[]): MarketplaceRepositoryContract { - return { - async findPublishedInvoices(filters) { - const statuses = filters.status && filters.status.length > 0 ? filters.status : [InvoiceStatus.PUBLISHED]; - let matched = invoices.filter((invoice) => statuses.includes(invoice.status)); - - // Apply sorting - const sortColumn = filters.sort || "amount"; - const sortOrder = filters.sortOrder || "DESC"; - - matched = [...matched].sort((a, b) => { - let comparison: number; - - if (sortColumn === "amount") { - comparison = parseFloat(a.amount) - parseFloat(b.amount); - } else if (sortColumn === "due_date") { - comparison = a.dueDate.getTime() - b.dueDate.getTime(); - } else if (sortColumn === "discount_rate") { - comparison = parseFloat(a.discountRate) - parseFloat(b.discountRate); - } else if (sortColumn === "created_at") { - comparison = a.createdAt.getTime() - b.createdAt.getTime(); - } else { - comparison = 0; - } - - return sortOrder === "DESC" ? -comparison : comparison; - }); - - return { invoices: matched, total: matched.length }; - }, - }; + return { + async findPublishedInvoices(filters) { + const statuses = + filters.status && filters.status.length > 0 ? filters.status : [InvoiceStatus.PUBLISHED]; + let matched = invoices.filter((invoice) => statuses.includes(invoice.status)); + + // Apply sorting + const sortColumn = filters.sort || "amount"; + const sortOrder = filters.sortOrder || "DESC"; + + matched = [...matched].sort((a, b) => { + let comparison: number; + + if (sortColumn === "amount") { + comparison = parseFloat(a.amount) - parseFloat(b.amount); + } else if (sortColumn === "due_date") { + comparison = a.dueDate.getTime() - b.dueDate.getTime(); + } else if (sortColumn === "discount_rate") { + comparison = parseFloat(a.discountRate) - parseFloat(b.discountRate); + } else if (sortColumn === "created_at") { + comparison = a.createdAt.getTime() - b.createdAt.getTime(); + } else { + comparison = 0; + } + + return sortOrder === "DESC" ? -comparison : comparison; + }); + + return { invoices: matched, total: matched.length }; + }, + }; } function createInvoice(overrides: Partial = {}): Invoice { - return { - id: crypto.randomUUID(), - sellerId: crypto.randomUUID(), - invoiceNumber: `INV-${crypto.randomUUID().slice(0, 8)}`, - customerName: "Customer", - amount: "1000.0000", - discountRate: "5.00", - netAmount: "950.0000", - dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - ipfsHash: "QmTestHash", - riskScore: null, - status: InvoiceStatus.DRAFT, - smartContractId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - seller: undefined as unknown as Invoice["seller"], - investments: [], - transactions: [], - ...overrides, - } as Invoice; + return { + id: crypto.randomUUID(), + sellerId: crypto.randomUUID(), + invoiceNumber: `INV-${crypto.randomUUID().slice(0, 8)}`, + customerName: "Customer", + amount: "1000.0000", + discountRate: "5.00", + netAmount: "950.0000", + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ipfsHash: "QmTestHash", + riskScore: null, + status: InvoiceStatus.DRAFT, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + seller: undefined as unknown as Invoice["seller"], + investments: [], + transactions: [], + ...overrides, + } as Invoice; } describe("Marketplace sorting: default sort by face value descending", () => { - const now = Date.now(); - - // Seed 5 invoices with varying face values and distinct due dates - const invoice5000 = createInvoice({ - amount: "5000.0000", - status: InvoiceStatus.PUBLISHED, - invoiceNumber: "INV-5000", - dueDate: new Date(now + 10 * 24 * 60 * 60 * 1000), // 10 days - }); - const invoice50000 = createInvoice({ - amount: "50000.0000", - status: InvoiceStatus.PUBLISHED, - invoiceNumber: "INV-50000", - dueDate: new Date(now + 50 * 24 * 60 * 60 * 1000), // 50 days - }); - const invoice1000 = createInvoice({ - amount: "1000.0000", - status: InvoiceStatus.PUBLISHED, - invoiceNumber: "INV-1000", - dueDate: new Date(now + 5 * 24 * 60 * 60 * 1000), // 5 days - }); - const invoice25000 = createInvoice({ - amount: "25000.0000", - status: InvoiceStatus.PUBLISHED, - invoiceNumber: "INV-25000", - dueDate: new Date(now + 30 * 24 * 60 * 60 * 1000), // 30 days - }); - const invoice10000 = createInvoice({ - amount: "10000.0000", - status: InvoiceStatus.PUBLISHED, - invoiceNumber: "INV-10000", - dueDate: new Date(now + 20 * 24 * 60 * 60 * 1000), // 20 days + const now = Date.now(); + + // Seed 5 invoices with varying face values and distinct due dates + const invoice5000 = createInvoice({ + amount: "5000.0000", + status: InvoiceStatus.PUBLISHED, + invoiceNumber: "INV-5000", + dueDate: new Date(now + 10 * 24 * 60 * 60 * 1000), // 10 days + }); + const invoice50000 = createInvoice({ + amount: "50000.0000", + status: InvoiceStatus.PUBLISHED, + invoiceNumber: "INV-50000", + dueDate: new Date(now + 50 * 24 * 60 * 60 * 1000), // 50 days + }); + const invoice1000 = createInvoice({ + amount: "1000.0000", + status: InvoiceStatus.PUBLISHED, + invoiceNumber: "INV-1000", + dueDate: new Date(now + 5 * 24 * 60 * 60 * 1000), // 5 days + }); + const invoice25000 = createInvoice({ + amount: "25000.0000", + status: InvoiceStatus.PUBLISHED, + invoiceNumber: "INV-25000", + dueDate: new Date(now + 30 * 24 * 60 * 60 * 1000), // 30 days + }); + const invoice10000 = createInvoice({ + amount: "10000.0000", + status: InvoiceStatus.PUBLISHED, + invoiceNumber: "INV-10000", + dueDate: new Date(now + 20 * 24 * 60 * 60 * 1000), // 20 days + }); + + const allInvoices = [invoice5000, invoice50000, invoice1000, invoice25000, invoice10000]; + + function createService(): MarketplaceService { + return new MarketplaceService({ + marketplaceRepository: createFakeMarketplaceRepository(allInvoices), }); + } - const allInvoices = [invoice5000, invoice50000, invoice1000, invoice25000, invoice10000]; + it("returns invoices sorted by face value descending by default", async () => { + const marketplaceService = createService(); - function createService(): MarketplaceService { - return new MarketplaceService({ - marketplaceRepository: createFakeMarketplaceRepository(allInvoices), - }); - } + const result = await marketplaceService.getPublishedInvoices(); - it("returns invoices sorted by face value descending by default", async () => { - const marketplaceService = createService(); + expect(result.data).toHaveLength(5); - const result = await marketplaceService.getPublishedInvoices(); + // Expected order: $50,000, $25,000, $10,000, $5,000, $1,000 + const amounts = result.data.map((invoice) => parseFloat(invoice.amount)); + expect(amounts).toEqual([50000, 25000, 10000, 5000, 1000]); + }); - expect(result.data).toHaveLength(5); + it("respects ?sort=faceValue:asc parameter override", async () => { + const marketplaceService = createService(); - // Expected order: $50,000, $25,000, $10,000, $5,000, $1,000 - const amounts = result.data.map((invoice) => parseFloat(invoice.amount)); - expect(amounts).toEqual([50000, 25000, 10000, 5000, 1000]); + const result = await marketplaceService.getPublishedInvoices({ + sort: "amount", + sortOrder: "ASC", }); - it("respects ?sort=faceValue:asc parameter override", async () => { - const marketplaceService = createService(); + expect(result.data).toHaveLength(5); - const result = await marketplaceService.getPublishedInvoices( - { sort: "amount", sortOrder: "ASC" }, - ); + // Expected order: $1,000, $5,000, $10,000, $25,000, $50,000 + const amounts = result.data.map((invoice) => parseFloat(invoice.amount)); + expect(amounts).toEqual([1000, 5000, 10000, 25000, 50000]); + }); - expect(result.data).toHaveLength(5); + it("supports sorting by due_date ascending", async () => { + const marketplaceService = createService(); - // Expected order: $1,000, $5,000, $10,000, $25,000, $50,000 - const amounts = result.data.map((invoice) => parseFloat(invoice.amount)); - expect(amounts).toEqual([1000, 5000, 10000, 25000, 50000]); + const result = await marketplaceService.getPublishedInvoices({ + sort: "due_date", + sortOrder: "ASC", }); - it("supports sorting by due_date ascending", async () => { - const marketplaceService = createService(); + expect(result.data).toHaveLength(5); - const result = await marketplaceService.getPublishedInvoices( - { sort: "due_date", sortOrder: "ASC" }, - ); + // Verify due dates are in ascending order + const dueDates = result.data.map((invoice) => invoice.dueDate.getTime()); + for (let i = 1; i < dueDates.length; i++) { + expect(dueDates[i]).toBeGreaterThanOrEqual(dueDates[i - 1]); + } + }); - expect(result.data).toHaveLength(5); + it("supports sorting by due_date descending", async () => { + const marketplaceService = createService(); - // Verify due dates are in ascending order - const dueDates = result.data.map((invoice) => invoice.dueDate.getTime()); - for (let i = 1; i < dueDates.length; i++) { - expect(dueDates[i]).toBeGreaterThanOrEqual(dueDates[i - 1]); - } + const result = await marketplaceService.getPublishedInvoices({ + sort: "due_date", + sortOrder: "DESC", }); - it("supports sorting by due_date descending", async () => { - const marketplaceService = createService(); - - const result = await marketplaceService.getPublishedInvoices( - { sort: "due_date", sortOrder: "DESC" }, - ); - - expect(result.data).toHaveLength(5); + expect(result.data).toHaveLength(5); - // Verify due dates are in descending order - const dueDates = result.data.map((invoice) => invoice.dueDate.getTime()); - for (let i = 1; i < dueDates.length; i++) { - expect(dueDates[i]).toBeLessThanOrEqual(dueDates[i - 1]); - } - }); -}); \ No newline at end of file + // Verify due dates are in descending order + const dueDates = result.data.map((invoice) => invoice.dueDate.getTime()); + for (let i = 1; i < dueDates.length; i++) { + expect(dueDates[i]).toBeLessThanOrEqual(dueDates[i - 1]); + } + }); +}); diff --git a/tests/integration/notification-mark-read.integration.test.ts b/tests/integration/notification-mark-read.integration.test.ts index e698338..206f9a8 100644 --- a/tests/integration/notification-mark-read.integration.test.ts +++ b/tests/integration/notification-mark-read.integration.test.ts @@ -1,5 +1,9 @@ import crypto from "crypto"; -import { NotificationService, NotificationRepositoryContract, NotificationPage } from "../../src/services/notification.service"; +import { + NotificationService, + NotificationRepositoryContract, + NotificationPage, +} from "../../src/services/notification.service"; import { NotificationType } from "../../src/types/enums"; interface StoredNotification { @@ -15,7 +19,9 @@ interface StoredNotification { /** * In-memory notification repository for integration testing. */ -function createFakeNotificationRepository(): NotificationRepositoryContract & { store: StoredNotification[] } { +function createFakeNotificationRepository(): NotificationRepositoryContract & { + store: StoredNotification[]; +} { const store: StoredNotification[] = []; return { @@ -36,7 +42,9 @@ function createFakeNotificationRepository(): NotificationRepositoryContract & { }, async findByIdAndUserId(id, userId) { - return (store.find((n) => n.id === id && n.userId === userId) ?? null) as import("../../src/models/Notification.model").Notification | null; + return (store.find((n) => n.id === id && n.userId === userId) ?? null) as + | import("../../src/models/Notification.model").Notification + | null; }, async markRead(id, userId) { @@ -54,8 +62,16 @@ function createFakeNotificationRepository(): NotificationRepositoryContract & { const page = options.page ?? 1; const limit = options.limit ?? 20; return { - data: filtered.slice((page - 1) * limit, page * limit) as import("../../src/models/Notification.model").Notification[], - meta: { total: filtered.length, page, limit, totalPages: Math.ceil(filtered.length / limit) }, + data: filtered.slice( + (page - 1) * limit, + page * limit + ) as import("../../src/models/Notification.model").Notification[], + meta: { + total: filtered.length, + page, + limit, + totalPages: Math.ceil(filtered.length / limit), + }, }; }, }; @@ -75,7 +91,7 @@ describe("Notification mark-as-read integration", () => { "wallet-1", NotificationType.INVOICE, "Invoice published", - "Your invoice has been published.", + "Your invoice has been published." ); expect(notif.read).toBe(false); @@ -106,7 +122,7 @@ describe("Notification mark-as-read integration", () => { "wallet-1", NotificationType.PAYMENT, "Settlement", - "Settlement complete.", + "Settlement complete." ); await service.markNotificationRead(notif.id, "wallet-1"); @@ -120,11 +136,11 @@ describe("Notification mark-as-read integration", () => { "wallet-1", NotificationType.INVOICE, "Invoice", - "desc", + "desc" ); - await expect( - service.markNotificationRead(notif.id, "wallet-2"), - ).rejects.toThrow("Notification not found"); + await expect(service.markNotificationRead(notif.id, "wallet-2")).rejects.toThrow( + "Notification not found" + ); }); }); diff --git a/tests/integration/orchestrate-investment-funding.integration.test.ts b/tests/integration/orchestrate-investment-funding.integration.test.ts index e3165ee..b370b2a 100644 --- a/tests/integration/orchestrate-investment-funding.integration.test.ts +++ b/tests/integration/orchestrate-investment-funding.integration.test.ts @@ -1,9 +1,7 @@ import crypto from "crypto"; import { Investment } from "../../src/models/Investment.model"; import { Transaction } from "../../src/models/Transaction.model"; -import { - OrchestrateInvestmentFundingService, -} from "../../src/services/stellar/orchestrate-investment-funding.service"; +import { OrchestrateInvestmentFundingService } from "../../src/services/stellar/orchestrate-investment-funding.service"; import { InvestmentStatus, TransactionStatus, TransactionType } from "../../src/types/enums"; function createInvestment(overrides: Partial = {}): Investment { diff --git a/tests/integration/seller-dashboard-aggregates.integration.test.ts b/tests/integration/seller-dashboard-aggregates.integration.test.ts index d2486f2..0a80e3a 100644 --- a/tests/integration/seller-dashboard-aggregates.integration.test.ts +++ b/tests/integration/seller-dashboard-aggregates.integration.test.ts @@ -133,14 +133,14 @@ describe("Seller dashboard: aggregates scoped to authenticated seller only", () // Assert seller A published total is 2000 const sellerAPublished = sellerAResult.invoices.filter( - (inv) => inv.status === InvoiceStatus.PUBLISHED, + (inv) => inv.status === InvoiceStatus.PUBLISHED ); expect(sellerAPublished).toHaveLength(1); expect(sellerAPublished[0].amount).toBe("2000.0000"); // Assert seller A settled total is 3000 const sellerASettled = sellerAResult.invoices.filter( - (inv) => inv.status === InvoiceStatus.SETTLED, + (inv) => inv.status === InvoiceStatus.SETTLED ); expect(sellerASettled).toHaveLength(1); expect(sellerASettled[0].amount).toBe("3000.0000"); @@ -148,7 +148,7 @@ describe("Seller dashboard: aggregates scoped to authenticated seller only", () // No seller B invoices in seller A's result const sellerBInvoiceNumbers = ["INV-B-001", "INV-B-002"]; expect( - sellerAResult.invoices.some((inv) => sellerBInvoiceNumbers.includes(inv.invoiceNumber)), + sellerAResult.invoices.some((inv) => sellerBInvoiceNumbers.includes(inv.invoiceNumber)) ).toBe(false); // Call seller dashboard as seller B @@ -164,14 +164,14 @@ describe("Seller dashboard: aggregates scoped to authenticated seller only", () // Assert seller B published total is 5000 const sellerBPublished = sellerBResult.invoices.filter( - (inv) => inv.status === InvoiceStatus.PUBLISHED, + (inv) => inv.status === InvoiceStatus.PUBLISHED ); expect(sellerBPublished).toHaveLength(1); expect(sellerBPublished[0].amount).toBe("5000.0000"); // Assert seller B funded total is 4000 const sellerBFunded = sellerBResult.invoices.filter( - (inv) => inv.status === InvoiceStatus.FUNDED, + (inv) => inv.status === InvoiceStatus.FUNDED ); expect(sellerBFunded).toHaveLength(1); expect(sellerBFunded[0].amount).toBe("4000.0000"); @@ -179,7 +179,7 @@ describe("Seller dashboard: aggregates scoped to authenticated seller only", () // No seller A invoices in seller B's result const sellerAInvoiceNumbers = ["INV-A-001", "INV-A-002"]; expect( - sellerBResult.invoices.some((inv) => sellerAInvoiceNumbers.includes(inv.invoiceNumber)), + sellerBResult.invoices.some((inv) => sellerAInvoiceNumbers.includes(inv.invoiceNumber)) ).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/tests/integration/seller-invoice-list.integration.test.ts b/tests/integration/seller-invoice-list.integration.test.ts index 0021209..ff3b975 100644 --- a/tests/integration/seller-invoice-list.integration.test.ts +++ b/tests/integration/seller-invoice-list.integration.test.ts @@ -52,7 +52,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { email: "sellerA@test.com", userType: UserType.SELLER, kycStatus: KYCStatus.APPROVED, - }), + }) ); sellerB = await userRepository.save( @@ -61,7 +61,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { email: "sellerB@test.com", userType: UserType.SELLER, kycStatus: KYCStatus.APPROVED, - }), + }) ); const invoiceRepository = dataSource.getRepository(Invoice); @@ -81,7 +81,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { netAmount: "950.0000", dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), ...overrides, - }), + }) ); } @@ -99,7 +99,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { netAmount: "1900.0000", dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), ...overrides, - }), + }) ); } @@ -128,7 +128,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { const sellerBInvoiceNumbers = ["INV-B-001", "INV-B-002"]; expect( - result.invoices.some((invoice) => sellerBInvoiceNumbers.includes(invoice.invoiceNumber)), + result.invoices.some((invoice) => sellerBInvoiceNumbers.includes(invoice.invoiceNumber)) ).toBe(false); }); @@ -144,7 +144,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { const statuses = result.invoices.map((invoice) => invoice.status); expect(statuses).toEqual( - expect.arrayContaining([InvoiceStatus.DRAFT, InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED]), + expect.arrayContaining([InvoiceStatus.DRAFT, InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED]) ); }); @@ -164,7 +164,7 @@ describe("Seller invoice list integration: no cross-seller leakage", () => { const statuses = result.invoices.map((invoice) => invoice.status); expect(statuses).toEqual( - expect.arrayContaining([InvoiceStatus.PUBLISHED, InvoiceStatus.SETTLED]), + expect.arrayContaining([InvoiceStatus.PUBLISHED, InvoiceStatus.SETTLED]) ); }); }); diff --git a/tests/integration/settlement.integration.test.ts b/tests/integration/settlement.integration.test.ts index c6c9d72..6685b43 100644 --- a/tests/integration/settlement.integration.test.ts +++ b/tests/integration/settlement.integration.test.ts @@ -18,14 +18,17 @@ function createFakeDataSource(invoice: Invoice) { const investments = new Map(); type FakeManager = { - createQueryBuilder: (entity: unknown, alias: string) => { + createQueryBuilder: ( + entity: unknown, + alias: string + ) => { setLock: () => unknown; where: (clause: string, params: { id: string }) => unknown; getOne: () => Promise; }; find: ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => Promise; create: (entity: unknown, data: Partial) => Investment | Partial; save: (entity: unknown, data: Investment | Invoice) => Promise; @@ -40,22 +43,22 @@ function createFakeDataSource(invoice: Invoice) { targetId = params.id; return builder; }, - getOne: async () => (targetId ? invoices.get(targetId) ?? null : null), + getOne: async () => (targetId ? (invoices.get(targetId) ?? null) : null), }; return builder; }, find: async ( entity: unknown, - options: { where: Record | Record[] }, + options: { where: Record | Record[] } ) => { if (entity === Investment) { const whereClauses = Array.isArray(options.where) ? options.where : [options.where]; return [...investments.values()].filter((investment) => whereClauses.some((clause) => Object.entries(clause).every( - ([key, value]) => (investment as unknown as Record)[key] === value, - ), - ), + ([key, value]) => (investment as unknown as Record)[key] === value + ) + ) ); } return []; @@ -77,8 +80,7 @@ function createFakeDataSource(invoice: Invoice) { }; const dataSource = { - transaction: async (callback: (manager: FakeManager) => Promise) => - callback(manager), + transaction: async (callback: (manager: FakeManager) => Promise) => callback(manager), } as unknown as DataSource; return { dataSource, invoices, investments }; @@ -180,6 +182,13 @@ describe("Settlement integration: rejecting settlement of non-fully-funded invoi jest.clearAllMocks(); }); + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }) + ).rejects.toThrow(/Cannot settle an invoice with status published/); it("should reject settlement of a published invoice (no investments)", async () => { try { const invoice = createInvoice({ status: InvoiceStatus.PUBLISHED }); @@ -228,7 +237,7 @@ describe("Settlement integration: rejecting settlement of non-fully-funded invoi invoiceId: invoice.id, proceeds: "6000.0000", actorWallet: "GADMIN", - }), + }) ).rejects.toThrow(/Cannot settle an invoice with status published/); }); @@ -330,6 +339,9 @@ describe("Settlement integration: funding multiple investors then settling", () actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", }); + const returnByInvestor = new Map( + result.settlements.map((settlement) => [settlement.investorId, settlement.actualReturn]) + ); const returnByInvestor = new Map( result.settlements.map((settlement) => [settlement.investorId, settlement.actualReturn]), ); @@ -340,6 +352,11 @@ describe("Settlement integration: funding multiple investors then settling", () expect(result.status).toBe(InvoiceStatus.SETTLED); expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.SETTLED); + const sumOfReturns = result.settlements.reduce( + (sum, settlement) => sum + Number(settlement.actualReturn), + 0 + ); + expect(sumOfReturns).toBeCloseTo(6600, 4); const sumOfReturns = result.settlements.reduce( (sum, settlement) => sum + Number(settlement.actualReturn), 0, @@ -388,6 +405,10 @@ describe("Settlement integration: funding multiple investors then settling", () actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", }); + const completionCall = infoSpy.mock.calls.find( + ([message]) => message === "Settlement flow completed." + ); + expect(completionCall).toBeDefined(); const completionCall = infoSpy.mock.calls.find( ([message]) => message === "Settlement flow completed.", ); @@ -417,9 +438,13 @@ describe("Settlement integration: funding multiple investors then settling", () invoiceId: invoice.id, proceeds: "6600.0000", actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", - }), + }) ).rejects.toThrow(); + const completionCall = infoSpy.mock.calls.find( + ([message]) => message === "Settlement flow completed." + ); + expect(completionCall).toBeUndefined(); expect(findSettlementCompletionLog(infoSpy)).toBeUndefined(); }); }); diff --git a/tests/integration/webhook-delivery-idempotency.integration.test.ts b/tests/integration/webhook-delivery-idempotency.integration.test.ts index 01fb2fe..67a6c12 100644 --- a/tests/integration/webhook-delivery-idempotency.integration.test.ts +++ b/tests/integration/webhook-delivery-idempotency.integration.test.ts @@ -109,7 +109,7 @@ describe("Webhook delivery idempotency (issue #224)", () => { email: "idempotency@test.com", userType: "seller", kycStatus: "approved", - } as any), + } as any) )) as unknown as User; const subscriptionRepository = dataSource.getRepository(WebhookSubscription); @@ -120,7 +120,7 @@ describe("Webhook delivery idempotency (issue #224)", () => { secret: "whsec_subscription_secret", eventTypes: [EVENT_TYPE], active: true, - } as any), + } as any) )) as unknown as WebhookSubscription; }, 30000); @@ -174,7 +174,7 @@ describe("Webhook delivery idempotency (issue #224)", () => { "x-event-id": EVENT_ID, "x-signature": expect.any(String), }), - }), + }) ); // A delivered delivery record is persisted for this event + subscription. @@ -213,7 +213,7 @@ describe("Webhook delivery idempotency (issue #224)", () => { // The duplicate is identified as already handled in the delivery log lines. const alreadyHandled = loggerEntries.find( - (entry) => entry.level === "info" && entry.message === "webhook.event.already_handled", + (entry) => entry.level === "info" && entry.message === "webhook.event.already_handled" ); expect(alreadyHandled).toBeDefined(); expect(alreadyHandled?.metadata).toMatchObject({ @@ -248,7 +248,7 @@ describe("Webhook delivery idempotency (issue #224)", () => { // A failed delivery is NOT marked as already handled, so a later attempt // with the same event id is retried normally. const alreadyHandled = loggerEntries.find( - (entry) => entry.message === "webhook.event.already_handled", + (entry) => entry.message === "webhook.event.already_handled" ); expect(alreadyHandled).toBeUndefined(); }); diff --git a/tests/investment.routes.test.ts b/tests/investment.routes.test.ts index 476e04b..b20ae70 100644 --- a/tests/investment.routes.test.ts +++ b/tests/investment.routes.test.ts @@ -32,7 +32,7 @@ describe("Investment Routes", () => { createInvestmentRouter({ investmentService: mockInvestmentService, authService: mockAuthService, - }), + }) ); app.use(createErrorMiddleware(logger)); }); diff --git a/tests/investment.service.test.ts b/tests/investment.service.test.ts index 018bd06..3b31f3a 100644 --- a/tests/investment.service.test.ts +++ b/tests/investment.service.test.ts @@ -34,13 +34,14 @@ describe("InvestmentService", () => { investmentService = new InvestmentService(mockDataSource); }); - const getMockInvoice = () => ({ - id: "invoice-1", - sellerId: "seller-1", - amount: "1000.0000", - netAmount: "950.0000", - status: InvoiceStatus.PUBLISHED, - } as Invoice); + const getMockInvoice = () => + ({ + id: "invoice-1", + sellerId: "seller-1", + amount: "1000.0000", + netAmount: "950.0000", + status: InvoiceStatus.PUBLISHED, + }) as Invoice; it("should create a PENDING investment when within capacity", async () => { const mockInvoice = getMockInvoice(); @@ -89,9 +90,7 @@ describe("InvestmentService", () => { it("should reject investment if it exceeds capacity", async () => { const mockInvoice = getMockInvoice(); mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); - mockEntityManager.find.mockResolvedValue([ - { investmentAmount: "500.0000" } as Investment, - ]); + mockEntityManager.find.mockResolvedValue([{ investmentAmount: "500.0000" } as Investment]); const input = { invoiceId: "invoice-1", @@ -101,7 +100,10 @@ describe("InvestmentService", () => { }; await expect(investmentService.createInvestment(input)).rejects.toThrow( - new ServiceError("INSUFFICIENT_CAPACITY", "Investment amount 500 exceeds remaining capacity 450"), + new ServiceError( + "INSUFFICIENT_CAPACITY", + "Investment amount 500 exceeds remaining capacity 450" + ) ); }); @@ -117,7 +119,7 @@ describe("InvestmentService", () => { }; await expect(investmentService.createInvestment(input)).rejects.toThrow( - new ServiceError("SELF_DEALING", "Investors cannot invest in their own invoices"), + new ServiceError("SELF_DEALING", "Investors cannot invest in their own invoices") ); }); @@ -130,7 +132,7 @@ describe("InvestmentService", () => { }; await expect(investmentService.createInvestment(input)).rejects.toThrow( - new ServiceError("INVALID_AMOUNT", "Investment amount must be greater than zero"), + new ServiceError("INVALID_AMOUNT", "Investment amount must be greater than zero") ); }); }); diff --git a/tests/invoice-lifecycle-log.test.ts b/tests/invoice-lifecycle-log.test.ts index 75ed4bc..bcbaedd 100644 --- a/tests/invoice-lifecycle-log.test.ts +++ b/tests/invoice-lifecycle-log.test.ts @@ -34,7 +34,7 @@ describe("logInvoiceTransition", () => { actor_wallet: "GABC...WXYZ", reason: "fully_funded", transitioned_at: expect.any(String), - }), + }) ); }); diff --git a/tests/invoice.routes.test.ts b/tests/invoice.routes.test.ts index 16e1010..474d989 100644 --- a/tests/invoice.routes.test.ts +++ b/tests/invoice.routes.test.ts @@ -28,10 +28,7 @@ describe("Invoice Routes", () => { }; const sellerId = "seller-123"; - const validToken = jwt.sign( - { sub: sellerId, stellarAddress: "GTEST123" }, - "test-secret", - ); + const validToken = jwt.sign({ sub: sellerId, stellarAddress: "GTEST123" }, "test-secret"); const mockInvoice = { id: "invoice-123", @@ -70,7 +67,7 @@ describe("Invoice Routes", () => { createInvoiceRouter({ invoiceService: mockInvoiceService, config: mockConfig as any, - }), + }) ); app.use(createErrorMiddleware(logger)); }); @@ -166,7 +163,7 @@ describe("Invoice Routes", () => { createInvoiceRouter({ invoiceService: mockInvoiceService, config: kycConfig as any, - }), + }) ); kycApp.use(createErrorMiddleware(logger)); @@ -198,7 +195,7 @@ describe("Invoice Routes", () => { it("should handle duplicate invoice number", async () => { mockInvoiceService.createInvoice.mockRejectedValue( - new ServiceError("invoice_number_exists", "Invoice number must be unique", 409), + new ServiceError("invoice_number_exists", "Invoice number must be unique", 409) ); await request(app) @@ -285,9 +282,7 @@ describe("Invoice Routes", () => { }); it("should reject unauthenticated requests", async () => { - await request(app) - .get("/api/v1/invoices") - .expect(401); + await request(app).get("/api/v1/invoices").expect(401); }); it("should validate pagination parameters", async () => { @@ -313,10 +308,7 @@ describe("Invoice Routes", () => { data: mockInvoice, }); - expect(mockInvoiceService.getInvoiceById).toHaveBeenCalledWith( - "invoice-123", - sellerId, - ); + expect(mockInvoiceService.getInvoiceById).toHaveBeenCalledWith("invoice-123", sellerId); }); it("should return 404 when invoice not found", async () => { @@ -333,8 +325,8 @@ describe("Invoice Routes", () => { new ServiceError( "unauthorized_invoice_access", "You do not have access to this invoice", - 403, - ), + 403 + ) ); await request(app) @@ -344,9 +336,7 @@ describe("Invoice Routes", () => { }); it("should reject unauthenticated requests", async () => { - await request(app) - .get("/api/v1/invoices/invoice-123") - .expect(401); + await request(app).get("/api/v1/invoices/invoice-123").expect(401); }); }); @@ -400,8 +390,8 @@ describe("Invoice Routes", () => { new ServiceError( "invalid_invoice_status", "Cannot update invoice in published status. Only draft invoices can be updated.", - 400, - ), + 400 + ) ); await request(app) @@ -418,8 +408,8 @@ describe("Invoice Routes", () => { new ServiceError( "unauthorized_invoice_access", "You can only update your own invoices", - 403, - ), + 403 + ) ); await request(app) @@ -450,19 +440,12 @@ describe("Invoice Routes", () => { .set("Authorization", `Bearer ${validToken}`) .expect(204); - expect(mockInvoiceService.deleteInvoice).toHaveBeenCalledWith( - "invoice-123", - sellerId, - ); + expect(mockInvoiceService.deleteInvoice).toHaveBeenCalledWith("invoice-123", sellerId); }); it("should reject deletion of published invoice", async () => { mockInvoiceService.deleteInvoice.mockRejectedValue( - new ServiceError( - "invalid_invoice_status", - "Cannot delete invoice in published status", - 400, - ), + new ServiceError("invalid_invoice_status", "Cannot delete invoice in published status", 400) ); await request(app) @@ -476,8 +459,8 @@ describe("Invoice Routes", () => { new ServiceError( "unauthorized_invoice_access", "You can only delete your own invoices", - 403, - ), + 403 + ) ); await request(app) @@ -487,9 +470,7 @@ describe("Invoice Routes", () => { }); it("should reject unauthenticated requests", async () => { - await request(app) - .delete("/api/v1/invoices/invoice-123") - .expect(401); + await request(app).delete("/api/v1/invoices/invoice-123").expect(401); }); }); @@ -523,8 +504,8 @@ describe("Invoice Routes", () => { new ServiceError( "invalid_status_transition", "Cannot transition from settled to published", - 400, - ), + 400 + ) ); await request(app) @@ -538,8 +519,8 @@ describe("Invoice Routes", () => { new ServiceError( "unauthorized_invoice_access", "You can only publish your own invoices", - 403, - ), + 403 + ) ); await request(app) @@ -549,9 +530,7 @@ describe("Invoice Routes", () => { }); it("should reject unauthenticated requests", async () => { - await request(app) - .post("/api/v1/invoices/invoice-123/publish") - .expect(401); + await request(app).post("/api/v1/invoices/invoice-123/publish").expect(401); }); }); @@ -606,7 +585,7 @@ describe("Invoice Routes", () => { it("should handle service errors", async () => { mockInvoiceService.uploadDocument.mockRejectedValue( - new ServiceError("invoice_not_found", "Invoice not found", 404), + new ServiceError("invoice_not_found", "Invoice not found", 404) ); await request(app) @@ -621,8 +600,8 @@ describe("Invoice Routes", () => { new ServiceError( "unauthorized_invoice_access", "You can only upload documents to your own invoices", - 403, - ), + 403 + ) ); await request(app) diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 5b69a43..9126f26 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -86,6 +86,57 @@ describe("InvoiceService", () => { }); }); + it("should calculate net amount correctly", async () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + mockInvoiceRepository.create.mockReturnValue({ + ...mockInvoice, + amount: "1000.00", + discountRate: "10.00", + }); + mockInvoiceRepository.save.mockResolvedValue({ + ...mockInvoice, + amount: "1000.00", + discountRate: "10.00", + netAmount: "900.0000", + }); + + const result = await invoiceService.createInvoice({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: new Date("2024-12-31"), + }); + + expect(result.netAmount).toBe("900.0000"); + }); + + it("should calculate net amount precisely for values where floating-point arithmetic rounds wrong", async () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + mockInvoiceRepository.create.mockImplementation((data: Partial) => ({ + ...mockInvoice, + ...data, + })); + mockInvoiceRepository.save.mockImplementation(async (invoice: Invoice) => invoice); + + // 29.99 - 29.99 * 0.5 / 100: naive `parseFloat` arithmetic here used to + // produce "29.8400" instead of the correct "29.8401" because 29.99 and + // 0.5 aren't exactly representable as IEEE-754 doubles. + const result = await invoiceService.createInvoice({ + sellerId: "seller-456", + invoiceNumber: "INV-002", + customerName: "Test Customer", + amount: "29.99", + discountRate: "0.5", + dueDate: new Date("2024-12-31"), + }); + + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ netAmount: "29.8401" }) + ); + expect(result.netAmount).toBe("29.8401"); + }); // netAmount = amount - amount * discountRate / 100, rounded to 4 dp. // The 29.99 @ 0.5% row guards a real regression: naive `parseFloat` // arithmetic produced "29.8400" instead of "29.8401" because 29.99 and @@ -352,7 +403,10 @@ describe("InvoiceService", () => { ...mockInvoice, dueDate: new Date(Date.now() + 48 * 60 * 60 * 1000), ipfsHash: "QmTestHash", - seller: { kycStatus: "approved", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "approved", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, } as Invoice; it("should transition draft invoice to published", async () => { @@ -372,7 +426,10 @@ describe("InvoiceService", () => { const soonDueInvoice = { ...mockInvoice, dueDate: new Date(Date.now() + 60 * 60 * 1000), // 1 hour in future - seller: { kycStatus: "approved", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "approved", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(soonDueInvoice); @@ -392,7 +449,10 @@ describe("InvoiceService", () => { ...mockInvoice, status: InvoiceStatus.SETTLED, dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - seller: { kycStatus: "approved", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "approved", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(settledInvoice); @@ -439,7 +499,10 @@ describe("InvoiceService", () => { const invoiceWithPendingKYC = { ...publishableInvoice, status: InvoiceStatus.DRAFT, - seller: { kycStatus: "pending", stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV" }, + seller: { + kycStatus: "pending", + stellarAddress: "GSELLERWALLET1234567890ABCDEFGHIJKLMNOPQRSTUV", + }, }; mockInvoiceRepository.findOne.mockResolvedValue(invoiceWithPendingKYC); @@ -462,7 +525,7 @@ describe("InvoiceService", () => { invoiceService.publishInvoice({ invoiceId: "invoice-123", sellerId: "seller-456", - }), + }) ).rejects.toMatchObject({ code: "invoice_not_publishable", statusCode: 400, diff --git a/tests/kyc.controller.test.ts b/tests/kyc.controller.test.ts index 9e0ae31..20fec6d 100644 --- a/tests/kyc.controller.test.ts +++ b/tests/kyc.controller.test.ts @@ -12,7 +12,10 @@ function response() { describe("KYC webhook controller", () => { it("rejects an invalid provider signature", async () => { - const service = { verifyWebhookSignature: jest.fn().mockReturnValue(false), processWebhook: jest.fn() }; + const service = { + verifyWebhookSignature: jest.fn().mockReturnValue(false), + processWebhook: jest.fn(), + }; const controller = createKycController(service as any); const res = response(); await controller.webhook({ body: Buffer.from("{}"), header: () => "bad" } as any, res); diff --git a/tests/kyc.service.test.ts b/tests/kyc.service.test.ts index 8398497..e4bc3a4 100644 --- a/tests/kyc.service.test.ts +++ b/tests/kyc.service.test.ts @@ -23,12 +23,20 @@ function createHarness() { findOne: jest.fn().mockResolvedValue(verification), }; const manager = { - getRepository: jest.fn((entity) => entity.name === "User" ? userRepository : verificationRepository), + getRepository: jest.fn((entity) => + entity.name === "User" ? userRepository : verificationRepository + ), }; const dataSource = { transaction: jest.fn(async (callback) => callback(manager)), }; - const appLogger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn(), child: jest.fn() }; + const appLogger = { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn(), + }; const service = new KycService(dataSource as any, "webhook-secret", appLogger as any); return { service, userRepository, verificationRepository, appLogger }; } @@ -40,10 +48,12 @@ describe("KycService", () => { providerReference: "provider-123", }); expect(result.status).toBe(KYCStatus.PENDING); - expect(verificationRepository.create).toHaveBeenCalledWith(expect.objectContaining({ - verificationType: KYCVerificationType.IDENTITY, - documents: { providerReference: "provider-123" }, - })); + expect(verificationRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + verificationType: KYCVerificationType.IDENTITY, + documents: { providerReference: "provider-123" }, + }) + ); expect(userRepository.update).toHaveBeenCalledWith("user-1", { kycStatus: KYCStatus.PENDING, isKycVerified: false, @@ -65,12 +75,20 @@ describe("KycService", () => { const { service, userRepository, verificationRepository, appLogger } = createHarness(); await service.processWebhook({ userId: "user-1", status }); expect(verificationRepository.save).toHaveBeenCalledWith(expect.objectContaining({ status })); - expect(userRepository.update).toHaveBeenCalledWith("user-1", { kycStatus: status, isKycVerified }); - expect(appLogger.info).toHaveBeenCalledWith("kyc.webhook.processed", expect.objectContaining({ status })); + expect(userRepository.update).toHaveBeenCalledWith("user-1", { + kycStatus: status, + isKycVerified, + }); + expect(appLogger.info).toHaveBeenCalledWith( + "kyc.webhook.processed", + expect.objectContaining({ status }) + ); }); it("rejects unsupported webhook transitions", async () => { const { service } = createHarness(); - await expect(service.processWebhook({ userId: "user-1", status: KYCStatus.PENDING })).rejects.toMatchObject({ statusCode: 400 }); + await expect( + service.processWebhook({ userId: "user-1", status: KYCStatus.PENDING }) + ).rejects.toMatchObject({ statusCode: 400 }); }); }); diff --git a/tests/kyc.test.ts b/tests/kyc.test.ts index b872b03..2f28f6a 100644 --- a/tests/kyc.test.ts +++ b/tests/kyc.test.ts @@ -3,15 +3,11 @@ import { KYCStatus } from "@/types/enums"; describe("KYC check", () => { it("blocks non-approved users", () => { - expect(() => - requireApprovedKYC({ kycStatus: KYCStatus.PENDING }) - ).toThrow(); + expect(() => requireApprovedKYC({ kycStatus: KYCStatus.PENDING })).toThrow(); }); it("allows approved users", () => { - expect(() => - requireApprovedKYC({ kycStatus: KYCStatus.APPROVED }) - ).not.toThrow(); + expect(() => requireApprovedKYC({ kycStatus: KYCStatus.APPROVED })).not.toThrow(); }); }); @@ -23,4 +19,4 @@ describe("truncateWalletAddress", () => { it("returns short addresses unchanged", () => { expect(truncateWalletAddress("GABC")).toBe("GABC"); }); -}); \ No newline at end of file +}); diff --git a/tests/marketplace.repository.test.ts b/tests/marketplace.repository.test.ts index 8df22fc..1f90812 100644 --- a/tests/marketplace.repository.test.ts +++ b/tests/marketplace.repository.test.ts @@ -113,11 +113,11 @@ describe("TypeORMMarketplaceRepository", () => { expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( "CAST(invoice.amount AS DECIMAL) >= :minAmount", - { minAmount: 500 }, + { minAmount: 500 } ); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( "CAST(invoice.amount AS DECIMAL) <= :maxAmount", - { maxAmount: 2000 }, + { maxAmount: 2000 } ); }); @@ -196,14 +196,20 @@ describe("TypeORMMarketplaceRepository", () => { // Should only have the base where clause for deleted_at expect(mockQueryBuilder.where).toHaveBeenCalledTimes(1); expect(mockQueryBuilder.where).toHaveBeenCalledWith("invoice.deleted_at IS NULL"); - + // Should not have additional where clauses for optional filters (except default status) const andWhereCalls = mockQueryBuilder.andWhere.mock.calls; - expect(andWhereCalls.some(call => typeof call[0] === 'string' && call[0].includes("due_date"))).toBe(false); - expect(andWhereCalls.some(call => typeof call[0] === 'string' && call[0].includes("amount"))).toBe(false); - + expect( + andWhereCalls.some((call) => typeof call[0] === "string" && call[0].includes("due_date")) + ).toBe(false); + expect( + andWhereCalls.some((call) => typeof call[0] === "string" && call[0].includes("amount")) + ).toBe(false); + // Status filter should be applied with default value - expect(andWhereCalls.some(call => typeof call[0] === 'string' && call[0].includes("status"))).toBe(true); + expect( + andWhereCalls.some((call) => typeof call[0] === "string" && call[0].includes("status")) + ).toBe(true); }); it("should return both invoices and total count", async () => { @@ -212,7 +218,7 @@ describe("TypeORMMarketplaceRepository", () => { const result = await marketplaceService.getPublishedInvoices( { status: [InvoiceStatus.PUBLISHED] }, - { page: 1, limit: 20 }, + { page: 1, limit: 20 } ); expect(result.meta.total).toBe(25); @@ -221,4 +227,4 @@ describe("TypeORMMarketplaceRepository", () => { expect(mockQueryBuilder.getMany).toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/marketplace.routes.test.ts b/tests/marketplace.routes.test.ts index 439c7f0..8bc9ab0 100644 --- a/tests/marketplace.routes.test.ts +++ b/tests/marketplace.routes.test.ts @@ -20,7 +20,7 @@ describe("Marketplace Routes", () => { "/api/v1/marketplace", createMarketplaceRouter({ marketplaceService: mockMarketplaceService, - }), + }) ); app.use(createErrorMiddleware(logger)); }); @@ -51,9 +51,7 @@ describe("Marketplace Routes", () => { it("should return published invoices with default parameters", async () => { mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); - const response = await request(app) - .get("/api/v1/marketplace/invoices") - .expect(200); + const response = await request(app).get("/api/v1/marketplace/invoices").expect(200); expect(response.body).toEqual({ success: true, @@ -70,7 +68,7 @@ describe("Marketplace Routes", () => { sort: "amount", sortOrder: "DESC", }, - { page: 1, limit: 20 }, + { page: 1, limit: 20 } ); }); @@ -82,10 +80,10 @@ describe("Marketplace Routes", () => { .query({ page: 2, limit: 10 }) .expect(200); - expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( - expect.any(Object), - { page: 2, limit: 10 }, - ); + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith(expect.any(Object), { + page: 2, + limit: 10, + }); }); it("should handle status filter as single value", async () => { @@ -100,7 +98,7 @@ describe("Marketplace Routes", () => { expect.objectContaining({ status: ["published"], }), - expect.any(Object), + expect.any(Object) ); }); @@ -116,7 +114,7 @@ describe("Marketplace Routes", () => { expect.objectContaining({ status: ["published", "funded"], }), - expect.any(Object), + expect.any(Object) ); }); @@ -133,7 +131,7 @@ describe("Marketplace Routes", () => { minAmount: 500, maxAmount: 2000, }), - expect.any(Object), + expect.any(Object) ); }); @@ -149,7 +147,7 @@ describe("Marketplace Routes", () => { expect.objectContaining({ dueBefore: new Date("2024-12-31T23:59:59.999Z"), }), - expect.any(Object), + expect.any(Object) ); }); @@ -166,7 +164,7 @@ describe("Marketplace Routes", () => { sort: "discount_rate", sortOrder: "DESC", }), - expect.any(Object), + expect.any(Object) ); }); @@ -200,12 +198,10 @@ describe("Marketplace Routes", () => { it("should handle service errors", async () => { mockMarketplaceService.getPublishedInvoices.mockRejectedValue( - new Error("Database connection failed"), + new Error("Database connection failed") ); - await request(app) - .get("/api/v1/marketplace/invoices") - .expect(500); + await request(app).get("/api/v1/marketplace/invoices").expect(500); }); it("should strip unknown query parameters", async () => { @@ -217,7 +213,7 @@ describe("Marketplace Routes", () => { page: 1, limit: 10, unknownParam: "should-be-stripped", - anotherUnknown: 123 + anotherUnknown: 123, }) .expect(200); @@ -232,7 +228,7 @@ describe("Marketplace Routes", () => { sort: "amount", sortOrder: "DESC", }, - { page: 1, limit: 10 }, + { page: 1, limit: 10 } ); }); @@ -262,8 +258,8 @@ describe("Marketplace Routes", () => { sort: "amount", sortOrder: "DESC", }, - { page: 2, limit: 5 }, + { page: 2, limit: 5 } ); }); }); -}); \ No newline at end of file +}); diff --git a/tests/marketplace.service.test.ts b/tests/marketplace.service.test.ts index 841f8e8..f2cba4b 100644 --- a/tests/marketplace.service.test.ts +++ b/tests/marketplace.service.test.ts @@ -101,7 +101,7 @@ describe("MarketplaceService", () => { sort: "amount", sortOrder: "DESC", }, - { page: 1, limit: 20 }, + { page: 1, limit: 20 } ); }); @@ -133,7 +133,7 @@ describe("MarketplaceService", () => { expect(mockMarketplaceRepository.findPublishedInvoices).toHaveBeenCalledWith( filters, - pagination, + pagination ); }); @@ -148,7 +148,7 @@ describe("MarketplaceService", () => { expect(mockMarketplaceRepository.findPublishedInvoices).toHaveBeenCalledWith( expect.any(Object), - { page: 1, limit: 100 }, // Normalized values + { page: 1, limit: 100 } // Normalized values ); }); @@ -193,4 +193,4 @@ describe("MarketplaceService", () => { expect(result.meta.totalPages).toBe(3); // Math.ceil(25 / 10) }); }); -}); \ No newline at end of file +}); diff --git a/tests/notification.test.ts b/tests/notification.test.ts index 0238e96..aa61a44 100644 --- a/tests/notification.test.ts +++ b/tests/notification.test.ts @@ -60,10 +60,7 @@ function rejectAuthMiddleware() { // avoiding the double-auth problem with createNotificationRouter. // --------------------------------------------------------------------------- -function buildApp( - notificationService: NotificationService, - authUserId?: string, -) { +function buildApp(notificationService: NotificationService, authUserId?: string) { const app = express(); app.use(express.json()); @@ -90,9 +87,7 @@ function buildApp( // Mocked NotificationService // --------------------------------------------------------------------------- -function buildMockService( - overrides: Partial = {}, -): NotificationService { +function buildMockService(overrides: Partial = {}): NotificationService { const defaults = { createNotification: jest.fn(), listNotifications: jest.fn().mockResolvedValue({ @@ -126,7 +121,7 @@ describe("GET /api/v1/notifications", () => { expect(res.body.data).toHaveLength(1); expect(res.body.meta.total).toBe(1); expect(service.listNotifications).toHaveBeenCalledWith( - expect.objectContaining({ userId: "user-1" }), + expect.objectContaining({ userId: "user-1" }) ); }); @@ -137,7 +132,7 @@ describe("GET /api/v1/notifications", () => { await request(app).get("/api/v1/notifications?read=false"); expect(service.listNotifications).toHaveBeenCalledWith( - expect.objectContaining({ read: false }), + expect.objectContaining({ read: false }) ); }); @@ -148,7 +143,7 @@ describe("GET /api/v1/notifications", () => { await request(app).get(`/api/v1/notifications?type=${NotificationType.INVOICE}`); expect(service.listNotifications).toHaveBeenCalledWith( - expect.objectContaining({ type: NotificationType.INVOICE }), + expect.objectContaining({ type: NotificationType.INVOICE }) ); }); @@ -213,4 +208,4 @@ describe("PATCH /api/v1/notifications/:id/read", () => { expect(res.status).toBe(401); }); -}); \ No newline at end of file +}); diff --git a/tests/observability.test.ts b/tests/observability.test.ts index 2f1d5b8..30c49ec 100644 --- a/tests/observability.test.ts +++ b/tests/observability.test.ts @@ -13,7 +13,7 @@ interface LogEntry { class CaptureLogger implements AppLogger { constructor( readonly entries: LogEntry[] = [], - private readonly defaultMetadata: LogMetadata = {}, + private readonly defaultMetadata: LogMetadata = {} ) {} debug(message: string, metadata: LogMetadata = {}): void { @@ -99,12 +99,10 @@ describe("Observability", () => { expect(firstResponse.headers["x-request-id"]).toEqual(expect.any(String)); expect(secondResponse.headers["x-request-id"]).toEqual(expect.any(String)); - expect(firstResponse.headers["x-request-id"]).not.toBe( - secondResponse.headers["x-request-id"], - ); + expect(firstResponse.headers["x-request-id"]).not.toBe(secondResponse.headers["x-request-id"]); const requestLogs = logger.entries.filter( - (entry) => entry.level === "info" && entry.message === "HTTP request completed.", + (entry) => entry.level === "info" && entry.message === "HTTP request completed." ); expect(requestLogs).toHaveLength(2); @@ -112,7 +110,7 @@ describe("Observability", () => { expect.arrayContaining([ firstResponse.headers["x-request-id"], secondResponse.headers["x-request-id"], - ]), + ]) ); }); @@ -134,7 +132,6 @@ describe("Observability", () => { expect(response.body.requestId).toBe("client-request-id"); expect(response.body.data?.requestId).toBe("client-request-id"); - }); it("logs structured auth failure metadata after a 401 response", async () => { @@ -149,7 +146,7 @@ describe("Observability", () => { await request(app).get("/api/v1/auth/me").expect(401); const authFailureLog = logger.entries.find( - (entry) => entry.level === "warn" && entry.message === "API authentication failure.", + (entry) => entry.level === "warn" && entry.message === "API authentication failure." ); expect(authFailureLog).toBeDefined(); @@ -177,23 +174,19 @@ describe("Observability", () => { const metricsResponse = await request(app).get("/metrics").expect(200); expect(metricsResponse.headers["content-type"]).toContain("text/plain"); + expect(metricsResponse.text).toContain("# TYPE stellarsettle_http_requests_total counter"); expect(metricsResponse.text).toContain( - "# TYPE stellarsettle_http_requests_total counter", - ); - expect(metricsResponse.text).toContain( - 'stellarsettle_http_requests_total{method="GET",route="/health",status_class="2xx"} 1', - ); - expect(metricsResponse.text).toContain( - 'stellarsettle_http_requests_total{method="GET",route="/api/v1/auth/me",status_class="4xx"} 1', + 'stellarsettle_http_requests_total{method="GET",route="/health",status_class="2xx"} 1' ); expect(metricsResponse.text).toContain( - 'stellarsettle_http_requests_total{method="GET",route="unmatched",status_class="4xx"} 1', + 'stellarsettle_http_requests_total{method="GET",route="/api/v1/auth/me",status_class="4xx"} 1' ); expect(metricsResponse.text).toContain( - "# TYPE stellarsettle_http_request_duration_ms histogram", + 'stellarsettle_http_requests_total{method="GET",route="unmatched",status_class="4xx"} 1' ); expect(metricsResponse.text).toContain( - "# TYPE stellarsettle_process_uptime_seconds gauge", + "# TYPE stellarsettle_http_request_duration_ms histogram" ); + expect(metricsResponse.text).toContain("# TYPE stellarsettle_process_uptime_seconds gauge"); }); }); diff --git a/tests/orchestrate-investment-funding.logging.test.ts b/tests/orchestrate-investment-funding.logging.test.ts index 075a5d1..c3a1250 100644 --- a/tests/orchestrate-investment-funding.logging.test.ts +++ b/tests/orchestrate-investment-funding.logging.test.ts @@ -1,9 +1,7 @@ import crypto from "crypto"; import { Investment } from "../src/models/Investment.model"; import { Transaction } from "../src/models/Transaction.model"; -import { - OrchestrateInvestmentFundingService, -} from "../src/services/stellar/orchestrate-investment-funding.service"; +import { OrchestrateInvestmentFundingService } from "../src/services/stellar/orchestrate-investment-funding.service"; import { InvestmentStatus, TransactionStatus, TransactionType } from "../src/types/enums"; import type { AppLogger } from "../src/observability/logger"; @@ -100,7 +98,7 @@ describe("OrchestrateInvestmentFundingService structured escrow logging", () => function_name: "prepare_investment_funding", invoice_id: investment.invoiceId, submitted_at: expect.any(String), - }), + }) ); expect(logger.info).toHaveBeenCalledWith( @@ -112,7 +110,7 @@ describe("OrchestrateInvestmentFundingService structured escrow logging", () => tx_hash: "escrow-tx-hash", ledger: 555111, confirmed_at: expect.any(String), - }), + }) ); const [, debugMetadata] = logger.debug.mock.calls[0]; @@ -148,7 +146,7 @@ describe("OrchestrateInvestmentFundingService structured escrow logging", () => invoice_id: investment.invoiceId, submitted_at: expect.any(String), error_reason: "RPC unavailable", - }), + }) ); expect(logger.info).not.toHaveBeenCalled(); }); diff --git a/tests/orchestrate-investment-funding.service.test.ts b/tests/orchestrate-investment-funding.service.test.ts index 2273895..9ba6a6f 100644 --- a/tests/orchestrate-investment-funding.service.test.ts +++ b/tests/orchestrate-investment-funding.service.test.ts @@ -1,9 +1,7 @@ import crypto from "crypto"; import { Investment } from "../src/models/Investment.model"; import { Transaction } from "../src/models/Transaction.model"; -import { - OrchestrateInvestmentFundingService, -} from "../src/services/stellar/orchestrate-investment-funding.service"; +import { OrchestrateInvestmentFundingService } from "../src/services/stellar/orchestrate-investment-funding.service"; import { InvestmentStatus, TransactionStatus, TransactionType } from "../src/types/enums"; import { ServiceError } from "../src/utils/service-error"; @@ -147,13 +145,13 @@ describe("OrchestrateInvestmentFundingService", () => { findTransactionByInvestmentIdForUpdate: async () => null, saveTransaction, createTransaction: (input) => createTransaction(input), - }), + }) ), }, sorobanEscrowClient: { - prepareInvestmentFunding: jest.fn().mockRejectedValue( - new ServiceError("soroban_unavailable", "RPC unavailable", 503), - ), + prepareInvestmentFunding: jest + .fn() + .mockRejectedValue(new ServiceError("soroban_unavailable", "RPC unavailable", 503)), }, config: { enabled: true, diff --git a/tests/rate-limit-wallet.test.ts b/tests/rate-limit-wallet.test.ts index a139f22..aceb74b 100644 --- a/tests/rate-limit-wallet.test.ts +++ b/tests/rate-limit-wallet.test.ts @@ -1,278 +1,350 @@ import request from "supertest"; import express from "express"; import jwt from "jsonwebtoken"; -import { createWalletRateLimiter, resetRateLimitStores } from "../src/middleware/rate-limit-wallet.middleware"; +import { + createWalletRateLimiter, + resetRateLimitStores, +} from "../src/middleware/rate-limit-wallet.middleware"; import { createErrorMiddleware } from "../src/middleware/error.middleware"; import { logger } from "../src/observability/logger"; import { KYCStatus, UserType } from "../src/types/enums"; describe("Wallet-based rate limiting", () => { - const TEST_SECRET = "test-secret-rate-limit"; - const WALLET_A = "GAWalletA123456789012345678901234567890123456789012345"; - const WALLET_B = "GBWalletB123456789012345678901234567890123456789012345"; - - function createToken(walletAddress: string): string { - return jwt.sign( - { sub: walletAddress, stellarAddress: walletAddress }, - TEST_SECRET, - ); - } + const TEST_SECRET = "test-secret-rate-limit"; + const WALLET_A = "GAWalletA123456789012345678901234567890123456789012345"; + const WALLET_B = "GBWalletB123456789012345678901234567890123456789012345"; - let app: express.Application; + function createToken(walletAddress: string): string { + return jwt.sign({ sub: walletAddress, stellarAddress: walletAddress }, TEST_SECRET); + } - beforeEach(() => { - resetRateLimitStores(); + let app: express.Application; - process.env.JWT_SECRET = TEST_SECRET; + beforeEach(() => { + resetRateLimitStores(); - app = express(); - app.use(express.json()); - - // Create a test endpoint with wallet rate limiter (max 3 per 60s) - const testRateLimiter = createWalletRateLimiter( - { windowMs: 60_000, maxRequests: 3 }, - "test-endpoint", - ); - - app.post( - "/api/v1/test-rate-limit", - (req, res, next) => { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - const token = authHeader.slice(7); - try { - const payload = jwt.verify(token, TEST_SECRET) as any; - (req as any).user = { - id: payload.sub, - stellarAddress: payload.stellarAddress, - email: null, - userType: UserType.INVESTOR, - kycStatus: KYCStatus.APPROVED, - createdAt: new Date(), - updatedAt: new Date(), - }; - next(); - } catch { - res.status(401).json({ error: "Invalid token" }); - } - }, - testRateLimiter, - (_req, res) => { - res.status(200).json({ success: true }); - }, - ); - - app.use(createErrorMiddleware(logger)); - }); + process.env.JWT_SECRET = TEST_SECRET; - afterEach(() => { - delete process.env.JWT_SECRET; - }); + app = express(); + app.use(express.json()); - it("should allow requests up to the limit", async () => { - const tokenA = createToken(WALLET_A); + // Create a test endpoint with wallet rate limiter (max 3 per 60s) + const testRateLimiter = createWalletRateLimiter( + { windowMs: 60_000, maxRequests: 3 }, + "test-endpoint" + ); - // First 3 requests should succeed - for (let i = 0; i < 3; i++) { - await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(200); + app.post( + "/api/v1/test-rate-limit", + (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; } - }); - - it("should return 429 after exceeding the limit", async () => { - const tokenA = createToken(WALLET_A); - - // Exhaust the 3 request limit - for (let i = 0; i < 3; i++) { - await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(200); + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, TEST_SECRET) as any; + (req as any).user = { + id: payload.sub, + stellarAddress: payload.stellarAddress, + email: null, + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + }; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); } + }, + testRateLimiter, + (_req, res) => { + res.status(200).json({ success: true }); + } + ); + + app.use(createErrorMiddleware(logger)); + }); + + afterEach(() => { + delete process.env.JWT_SECRET; + }); + + it("should allow requests up to the limit", async () => { + const tokenA = createToken(WALLET_A); + + // First 3 requests should succeed + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } + }); - // 4th request should be rate limited - const response = await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(429); - - expect(response.body).toMatchObject({ - success: false, - error: { - code: "RATE_LIMIT_EXCEEDED", - }, - }); - }); - - it("should include Retry-After header in 429 response", async () => { - const tokenA = createToken(WALLET_A); - - // Exhaust the limit - for (let i = 0; i < 3; i++) { - await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(200); - } - - const response = await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(429); - - expect(response.headers).toHaveProperty("retry-after"); - const retryAfter = parseInt(response.headers["retry-after"], 10); - expect(retryAfter).toBeGreaterThanOrEqual(1); - expect(retryAfter).toBeLessThanOrEqual(60); - }); - - it("should not affect wallet B when wallet A is rate limited", async () => { - const tokenA = createToken(WALLET_A); - const tokenB = createToken(WALLET_B); + it("should return 429 after exceeding the limit", async () => { + const tokenA = createToken(WALLET_A); - // Exhaust wallet A's limit - for (let i = 0; i < 3; i++) { - await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(200); - } + // Exhaust the 3 request limit + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } - // Wallet A should be rate limited - await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenA}`) - .expect(429); - - // Wallet B should still be allowed - await request(app) - .post("/api/v1/test-rate-limit") - .set("Authorization", `Bearer ${tokenB}`) - .expect(200); + // 4th request should be rate limited + const response = await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + }, }); + }); - it("should allow requests again after the window resets", async () => { - // Use a very short window for testing - resetRateLimitStores(); - - const shortWindowApp = express(); - shortWindowApp.use(express.json()); - - const shortWindowLimiter = createWalletRateLimiter( - { windowMs: 100, maxRequests: 1 }, // 100ms window, 1 request max - "short-window-test", - ); - - shortWindowApp.post( - "/api/v1/short-window", - (req, res, next) => { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - const token = authHeader.slice(7); - try { - const payload = jwt.verify(token, TEST_SECRET) as any; - (req as any).user = { - id: payload.sub, - stellarAddress: payload.stellarAddress, - email: null, - userType: UserType.INVESTOR, - kycStatus: KYCStatus.APPROVED, - createdAt: new Date(), - updatedAt: new Date(), - }; - next(); - } catch { - res.status(401).json({ error: "Invalid token" }); - } - }, - shortWindowLimiter, - (_req, res) => { - res.status(200).json({ success: true }); - }, - ); + it("should include Retry-After header in 429 response", async () => { + const tokenA = createToken(WALLET_A); - shortWindowApp.use(createErrorMiddleware(logger)); + // Exhaust the limit + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } - const tokenA = createToken(WALLET_A); + const response = await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + expect(response.headers).toHaveProperty("retry-after"); + const retryAfter = parseInt(response.headers["retry-after"], 10); + expect(retryAfter).toBeGreaterThanOrEqual(1); + expect(retryAfter).toBeLessThanOrEqual(60); + }); + + it("should not affect wallet B when wallet A is rate limited", async () => { + const tokenA = createToken(WALLET_A); + const tokenB = createToken(WALLET_B); + + // Exhaust wallet A's limit + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } - // First request should succeed - await request(shortWindowApp) - .post("/api/v1/short-window") - .set("Authorization", `Bearer ${tokenA}`) - .expect(200); - - // Second request should be rate limited - await request(shortWindowApp) - .post("/api/v1/short-window") - .set("Authorization", `Bearer ${tokenA}`) - .expect(429); - - // Wait for window to reset - await new Promise((resolve) => setTimeout(resolve, 150)); - - // After window reset, requests should succeed again - await request(shortWindowApp) - .post("/api/v1/short-window") - .set("Authorization", `Bearer ${tokenA}`) - .expect(200); - }); + // Wallet A should be rate limited + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + // Wallet B should still be allowed + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenB}`) + .expect(200); + }); + + it("should allow requests again after the window resets", async () => { + // Use a very short window for testing + resetRateLimitStores(); + + const shortWindowApp = express(); + shortWindowApp.use(express.json()); + + const shortWindowLimiter = createWalletRateLimiter( + { windowMs: 100, maxRequests: 1 }, // 100ms window, 1 request max + "short-window-test" + ); + + shortWindowApp.post( + "/api/v1/short-window", + (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, TEST_SECRET) as any; + (req as any).user = { + id: payload.sub, + stellarAddress: payload.stellarAddress, + email: null, + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + }; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); + } + }, + shortWindowLimiter, + (_req, res) => { + res.status(200).json({ success: true }); + } + ); + + shortWindowApp.use(createErrorMiddleware(logger)); + + const tokenA = createToken(WALLET_A); + + // First request should succeed + await request(shortWindowApp) + .post("/api/v1/short-window") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + + // Second request should be rate limited + await request(shortWindowApp) + .post("/api/v1/short-window") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + // Wait for window to reset + await new Promise((resolve) => setTimeout(resolve, 150)); + + // After window reset, requests should succeed again + await request(shortWindowApp) + .post("/api/v1/short-window") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + }); + + it("should return 401 if no wallet address in request", async () => { + // Send request without auth + await request(app).post("/api/v1/test-rate-limit").expect(401); + }); + + it("should reset counter after window expires and allow full quota again", async () => { + resetRateLimitStores(); + + const shortApp = express(); + shortApp.use(express.json()); + + const limiter = createWalletRateLimiter({ windowMs: 150, maxRequests: 3 }, "reset-test"); + + shortApp.post( + "/test", + (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, TEST_SECRET) as any; + (req as any).user = { id: payload.sub, stellarAddress: payload.stellarAddress }; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); + } + }, + limiter, + (_req, res) => { + res.status(200).json({ success: true }); + } + ); - it("should return 401 if no wallet address in request", async () => { - // Send request without auth - await request(app) - .post("/api/v1/test-rate-limit") - .expect(401); - }); + shortApp.use(createErrorMiddleware(logger)); - it("should reset counter after window expires and allow full quota again", async () => { - resetRateLimitStores(); + const token = createToken(WALLET_A); - const shortApp = express(); - shortApp.use(express.json()); + // Exhaust the limit (3 requests) + for (let i = 0; i < 3; i++) { + await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(200); + } + // 4th request should be rate limited + await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(429); const limiter = createWalletRateLimiter( { windowMs: 400, maxRequests: 3 }, "reset-test", ); - shortApp.post("/test", (req, res, next) => { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { res.status(401).json({ error: "Unauthorized" }); return; } - const token = authHeader.slice(7); - try { - const payload = jwt.verify(token, TEST_SECRET) as any; - (req as any).user = { id: payload.sub, stellarAddress: payload.stellarAddress }; - next(); - } catch { res.status(401).json({ error: "Invalid token" }); } - }, limiter, (_req, res) => { res.status(200).json({ success: true }); }); + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, 200)); - shortApp.use(createErrorMiddleware(logger)); + // Counter should have reset — 3 more requests should succeed + for (let i = 0; i < 3; i++) { + await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(200); + } - const token = createToken(WALLET_A); + // 4th request after reset should be rate limited again + await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(429); + }); - // Exhaust the limit (3 requests) - for (let i = 0; i < 3; i++) { - await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(200); - } + it("should reset per wallet, not globally", async () => { + resetRateLimitStores(); - // 4th request should be rate limited - await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(429); + const shortApp = express(); + shortApp.use(express.json()); + const limiter = createWalletRateLimiter({ windowMs: 150, maxRequests: 2 }, "per-wallet-test"); // Wait for window to expire await new Promise((resolve) => setTimeout(resolve, 500)); - // Counter should have reset — 3 more requests should succeed - for (let i = 0; i < 3; i++) { - await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(200); + shortApp.post( + "/test", + (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, TEST_SECRET) as any; + (req as any).user = { id: payload.sub, stellarAddress: payload.stellarAddress }; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); } + }, + limiter, + (_req, res) => { + res.status(200).json({ success: true }); + } + ); + + shortApp.use(createErrorMiddleware(logger)); + + const tokenA = createToken(WALLET_A); + const tokenB = createToken(WALLET_B); + + // Exhaust wallet A + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenA}`).expect(200); + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenA}`).expect(200); + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenA}`).expect(429); + + // Wallet B should still have full quota + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenB}`).expect(200); + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenB}`).expect(200); + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenB}`).expect(429); + + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Both wallets should have fresh quotas + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenA}`).expect(200); + await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenB}`).expect(200); + }); +}); // 4th request after reset should be rate limited again await request(shortApp).post("/test").set("Authorization", `Bearer ${token}`).expect(429); @@ -322,4 +394,4 @@ describe("Wallet-based rate limiting", () => { await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenA}`).expect(200); await request(shortApp).post("/test").set("Authorization", `Bearer ${tokenB}`).expect(200); }); -}); \ No newline at end of file +}); diff --git a/tests/reconcile-pending-stellar-state.worker.test.ts b/tests/reconcile-pending-stellar-state.worker.test.ts index 50a827b..fbe12ad 100644 --- a/tests/reconcile-pending-stellar-state.worker.test.ts +++ b/tests/reconcile-pending-stellar-state.worker.test.ts @@ -16,7 +16,7 @@ interface LogEntry { class CaptureLogger implements AppLogger { constructor( readonly entries: LogEntry[] = [], - private readonly defaultMetadata: LogMetadata = {}, + private readonly defaultMetadata: LogMetadata = {} ) {} debug(message: string, metadata: LogMetadata = {}): void { @@ -74,7 +74,7 @@ class CaptureLogger implements AppLogger { function createCandidate( investmentId: string, stellarTxHash: string, - overrides: Partial = {}, + overrides: Partial = {} ): ReconciliationCandidate { return { investmentId, @@ -87,7 +87,7 @@ function createCandidate( function createVerifiedResult( investmentId: string, - outcome: "verified" | "already_verified", + outcome: "verified" | "already_verified" ): PaymentVerificationResult { return { outcome, @@ -107,22 +107,22 @@ describe("ReconcilePendingStellarStateWorker", () => { it("reconciles actionable candidates, continues after errors, and yields between items", async () => { const now = new Date("2026-01-01T00:10:00.000Z"); const repository = { - findPendingCandidates: jest.fn().mockResolvedValue([ - createCandidate("investment-1", "hash-1"), - createCandidate("investment-2", "hash-2"), - createCandidate("investment-3", "hash-3"), - ]), + findPendingCandidates: jest + .fn() + .mockResolvedValue([ + createCandidate("investment-1", "hash-1"), + createCandidate("investment-2", "hash-2"), + createCandidate("investment-3", "hash-3"), + ]), }; const paymentVerifier = { verifyPayment: jest .fn() .mockResolvedValueOnce(createVerifiedResult("investment-1", "verified")) .mockRejectedValueOnce( - new ServiceError("transaction_not_found", "Transaction not found.", 404), + new ServiceError("transaction_not_found", "Transaction not found.", 404) ) - .mockResolvedValueOnce( - createVerifiedResult("investment-3", "already_verified"), - ), + .mockResolvedValueOnce(createVerifiedResult("investment-3", "already_verified")), }; const yieldControl = jest.fn(async () => undefined); const logger = new CaptureLogger(); @@ -145,7 +145,7 @@ describe("ReconcilePendingStellarStateWorker", () => { expect(repository.findPendingCandidates).toHaveBeenCalledWith( new Date("2026-01-01T00:09:00.000Z"), - 3, + 3 ); expect(paymentVerifier.verifyPayment).toHaveBeenCalledTimes(3); expect(yieldControl).toHaveBeenCalledTimes(3); @@ -167,17 +167,19 @@ describe("ReconcilePendingStellarStateWorker", () => { level: "debug", message: "Completed Stellar reconciliation tick.", }), - ]), + ]) ); }); it("logs cycle start and completion once per cycle with matching cycle_id", async () => { const now = new Date("2026-01-01T00:10:00.000Z"); const repository = { - findPendingCandidates: jest.fn().mockResolvedValue([ - createCandidate("investment-1", "hash-1"), - createCandidate("investment-2", "hash-2"), - ]), + findPendingCandidates: jest + .fn() + .mockResolvedValue([ + createCandidate("investment-1", "hash-1"), + createCandidate("investment-2", "hash-2"), + ]), }; const paymentVerifier = { verifyPayment: jest @@ -204,10 +206,11 @@ describe("ReconcilePendingStellarStateWorker", () => { await worker.runTick(); const startLogs = logger.entries.filter( - (entry) => entry.level === "info" && entry.message === "Started Stellar reconciliation tick.", + (entry) => entry.level === "info" && entry.message === "Started Stellar reconciliation tick." ); const completionLogs = logger.entries.filter( - (entry) => entry.level === "debug" && entry.message === "Completed Stellar reconciliation tick.", + (entry) => + entry.level === "debug" && entry.message === "Completed Stellar reconciliation tick." ); expect(startLogs).toHaveLength(1); @@ -231,11 +234,13 @@ describe("ReconcilePendingStellarStateWorker", () => { it("stops starting new reconciliations once the tick runtime budget is exhausted", async () => { let currentTimeMs = Date.parse("2026-01-01T00:00:00.000Z"); const repository = { - findPendingCandidates: jest.fn().mockResolvedValue([ - createCandidate("investment-1", "hash-1"), - createCandidate("investment-2", "hash-2"), - createCandidate("investment-3", "hash-3"), - ]), + findPendingCandidates: jest + .fn() + .mockResolvedValue([ + createCandidate("investment-1", "hash-1"), + createCandidate("investment-2", "hash-2"), + createCandidate("investment-3", "hash-3"), + ]), }; const paymentVerifier = { verifyPayment: jest.fn(async (input: { investmentId: string }) => { @@ -359,7 +364,7 @@ describe("ReconcilePendingStellarStateWorker", () => { expect(repository.findPendingCandidates).toHaveBeenCalledTimes(1); expect(repository.findPendingCandidates).toHaveBeenCalledWith( new Date("2026-01-01T00:09:00.000Z"), - 10, + 10 ); // Pending investment was verified @@ -372,7 +377,8 @@ describe("ReconcilePendingStellarStateWorker", () => { // it is never touched during reconciliation. // Log should show 1 checked, 0 skipped (only 1 pending candidate existed) const completionLog = logger.entries.find( - (entry: LogEntry) => entry.level === "debug" && entry.message === "Completed Stellar reconciliation tick.", + (entry: LogEntry) => + entry.level === "debug" && entry.message === "Completed Stellar reconciliation tick." ); expect(completionLog).toBeDefined(); expect(completionLog?.metadata).toMatchObject({ @@ -386,11 +392,13 @@ describe("ReconcilePendingStellarStateWorker", () => { const logger = new CaptureLogger(); const repository = { - findPendingCandidates: jest.fn().mockResolvedValue([ - createCandidate("investment-1", "hash-1"), - createCandidate("investment-2", "hash-2"), - createCandidate("investment-3", "hash-3"), - ]), + findPendingCandidates: jest + .fn() + .mockResolvedValue([ + createCandidate("investment-1", "hash-1"), + createCandidate("investment-2", "hash-2"), + createCandidate("investment-3", "hash-3"), + ]), }; const paymentVerifier = { @@ -424,7 +432,8 @@ describe("ReconcilePendingStellarStateWorker", () => { expect(result.failed).toBe(0); const completionLog = logger.entries.find( - (entry: LogEntry) => entry.level === "debug" && entry.message === "Completed Stellar reconciliation tick.", + (entry: LogEntry) => + entry.level === "debug" && entry.message === "Completed Stellar reconciliation tick." ); expect(completionLog).toBeDefined(); expect(completionLog?.metadata).toMatchObject({ diff --git a/tests/settlement-completion-log.test.ts b/tests/settlement-completion-log.test.ts index 89e63ac..3362df8 100644 --- a/tests/settlement-completion-log.test.ts +++ b/tests/settlement-completion-log.test.ts @@ -29,7 +29,7 @@ describe("logSettlementCompletion", () => { total_proceeds: "12345.0000000", investor_count: 3, settled_at: expect.any(String), - }), + }) ); }); diff --git a/tests/unit/auth-failure.test.ts b/tests/unit/auth-failure.test.ts index 4e1f1fb..66e2171 100644 --- a/tests/unit/auth-failure.test.ts +++ b/tests/unit/auth-failure.test.ts @@ -1,8 +1,5 @@ import jwt from "jsonwebtoken"; -import { - buildAuthFailureDetails, - truncateWalletAddress, -} from "../../src/lib/auth-failure"; +import { buildAuthFailureDetails, truncateWalletAddress } from "../../src/lib/auth-failure"; describe("auth failure helpers", () => { it("truncates wallet addresses consistently", () => { @@ -12,11 +9,9 @@ describe("auth failure helpers", () => { }); it("extracts a truncated address from a parseable token", () => { - const token = jwt.sign( - { sub: "GABCDEFGHIJKLMNO1234567890" }, - "test-secret", - { expiresIn: "1h" }, - ); + const token = jwt.sign({ sub: "GABCDEFGHIJKLMNO1234567890" }, "test-secret", { + expiresIn: "1h", + }); expect(buildAuthFailureDetails(token, "invalid_signature")).toEqual({ authFailure: { diff --git a/tests/unit/cursor-pagination.test.ts b/tests/unit/cursor-pagination.test.ts index 33f12e0..b918dcb 100644 --- a/tests/unit/cursor-pagination.test.ts +++ b/tests/unit/cursor-pagination.test.ts @@ -1,91 +1,91 @@ import { encodeCursor, decodeCursor } from "../../src/utils/cursor-pagination.utils"; describe("encodeCursor / decodeCursor", () => { - it("encodes and decodes a cursor correctly", () => { - const date = new Date("2026-07-28T15:00:00.000Z"); - const id = "abc-123-def"; + it("encodes and decodes a cursor correctly", () => { + const date = new Date("2026-07-28T15:00:00.000Z"); + const id = "abc-123-def"; - const cursor = encodeCursor(date, id); - const decoded = decodeCursor(cursor); + const cursor = encodeCursor(date, id); + const decoded = decodeCursor(cursor); - expect(decoded.createdAt.toISOString()).toBe(date.toISOString()); - expect(decoded.id).toBe(id); - }); + expect(decoded.createdAt.toISOString()).toBe(date.toISOString()); + expect(decoded.id).toBe(id); + }); - it("produces a valid base64 string", () => { - const date = new Date(); - const id = "some-uuid-here"; - const cursor = encodeCursor(date, id); + it("produces a valid base64 string", () => { + const date = new Date(); + const id = "some-uuid-here"; + const cursor = encodeCursor(date, id); - // Should be a non-empty base64 string - expect(cursor).toBeTruthy(); - expect(typeof cursor).toBe("string"); + // Should be a non-empty base64 string + expect(cursor).toBeTruthy(); + expect(typeof cursor).toBe("string"); - // Should be decodable by standard base64 - const decoded = Buffer.from(cursor, "base64").toString("utf-8"); - expect(decoded).toContain("::"); - }); + // Should be decodable by standard base64 + const decoded = Buffer.from(cursor, "base64").toString("utf-8"); + expect(decoded).toContain("::"); + }); - it("handles UUIDs with hyphens and numbers", () => { - const date = new Date("2026-01-01T00:00:00.000Z"); - const id = "550e8400-e29b-41d4-a716-446655440000"; + it("handles UUIDs with hyphens and numbers", () => { + const date = new Date("2026-01-01T00:00:00.000Z"); + const id = "550e8400-e29b-41d4-a716-446655440000"; - const cursor = encodeCursor(date, id); - const decoded = decodeCursor(cursor); + const cursor = encodeCursor(date, id); + const decoded = decodeCursor(cursor); - expect(decoded.createdAt.toISOString()).toBe("2026-01-01T00:00:00.000Z"); - expect(decoded.id).toBe(id); - }); + expect(decoded.createdAt.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(decoded.id).toBe(id); + }); - it("handles dates with timezone offsets correctly", () => { - // Date with +05:30 offset - const date = new Date("2026-07-28T20:30:00.000+05:30"); - const id = "test-id"; + it("handles dates with timezone offsets correctly", () => { + // Date with +05:30 offset + const date = new Date("2026-07-28T20:30:00.000+05:30"); + const id = "test-id"; - const cursor = encodeCursor(date, id); - const decoded = decodeCursor(cursor); + const cursor = encodeCursor(date, id); + const decoded = decodeCursor(cursor); - // Should normalize to UTC - expect(decoded.createdAt.toISOString()).toBe("2026-07-28T15:00:00.000Z"); - expect(decoded.id).toBe(id); - }); + // Should normalize to UTC + expect(decoded.createdAt.toISOString()).toBe("2026-07-28T15:00:00.000Z"); + expect(decoded.id).toBe(id); + }); - it("throws on invalid cursor format (no separator)", () => { - const invalidCursor = Buffer.from("just-a-string-without-separator").toString("base64"); + it("throws on invalid cursor format (no separator)", () => { + const invalidCursor = Buffer.from("just-a-string-without-separator").toString("base64"); - expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); - }); + expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); + }); - it("throws on cursor with empty date component", () => { - const invalidCursor = Buffer.from("::some-id").toString("base64"); + it("throws on cursor with empty date component", () => { + const invalidCursor = Buffer.from("::some-id").toString("base64"); - expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); - }); + expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); + }); - it("throws on cursor with empty id component", () => { - const invalidCursor = Buffer.from("2026-07-28T15:00:00.000Z::").toString("base64"); + it("throws on cursor with empty id component", () => { + const invalidCursor = Buffer.from("2026-07-28T15:00:00.000Z::").toString("base64"); - expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); - }); + expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); + }); - it("throws on cursor with unparseable date", () => { - const invalidCursor = Buffer.from("not-a-date::some-id").toString("base64"); + it("throws on cursor with unparseable date", () => { + const invalidCursor = Buffer.from("not-a-date::some-id").toString("base64"); - expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); - }); + expect(() => decodeCursor(invalidCursor)).toThrow("Invalid cursor format"); + }); - it("throws on completely invalid base64 string", () => { - expect(() => decodeCursor("!!!not-valid-base64!!!")).toThrow(); - }); + it("throws on completely invalid base64 string", () => { + expect(() => decodeCursor("!!!not-valid-base64!!!")).toThrow(); + }); - it("supports ids containing pipe characters (:: is the separator)", () => { - const date = new Date("2026-07-28T15:00:00.000Z"); - const id = "pipe|in|id"; + it("supports ids containing pipe characters (:: is the separator)", () => { + const date = new Date("2026-07-28T15:00:00.000Z"); + const id = "pipe|in|id"; - const cursor = encodeCursor(date, id); - const decoded = decodeCursor(cursor); + const cursor = encodeCursor(date, id); + const decoded = decodeCursor(cursor); - expect(decoded.createdAt.toISOString()).toBe(date.toISOString()); - expect(decoded.id).toBe(id); - }); -}); \ No newline at end of file + expect(decoded.createdAt.toISOString()).toBe(date.toISOString()); + expect(decoded.id).toBe(id); + }); +}); diff --git a/tests/unit/extract-wallet-from-token.test.ts b/tests/unit/extract-wallet-from-token.test.ts index 6811999..7f982d6 100644 --- a/tests/unit/extract-wallet-from-token.test.ts +++ b/tests/unit/extract-wallet-from-token.test.ts @@ -53,7 +53,9 @@ describe("extractWalletFromToken", () => { }); it("returns the address for a valid Stellar address string sub", () => { - const token = jwt.sign({ sub: "GA5XZ7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7" }, SECRET, { expiresIn: "1h" }); + const token = jwt.sign({ sub: "GA5XZ7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7" }, SECRET, { + expiresIn: "1h", + }); expect(extractWalletFromToken(token, SECRET)).toBe("GA5XZ7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7Z7W7"); }); diff --git a/tests/unit/horizon-response.test.ts b/tests/unit/horizon-response.test.ts index e3cf756..6e2ca1a 100644 --- a/tests/unit/horizon-response.test.ts +++ b/tests/unit/horizon-response.test.ts @@ -11,7 +11,9 @@ describe("Horizon response normalization", () => { memo: null, memoType: "text", }); - expect(normalizeHorizonPayment({ type: "payment", amount: "5", to: "GABC", asset_code: "USDC" })).toMatchObject({ + expect( + normalizeHorizonPayment({ type: "payment", amount: "5", to: "GABC", asset_code: "USDC" }) + ).toMatchObject({ destination: "GABC", assetCode: "USDC", assetIssuer: null, @@ -20,6 +22,8 @@ describe("Horizon response normalization", () => { it("returns typed validation errors for malformed required fields", () => { expect(() => normalizeHorizonTransaction({})).toThrow(HorizonValidationError); - expect(() => normalizeHorizonPayment({ type: "payment", amount: 5 })).toThrow(HorizonValidationError); + expect(() => normalizeHorizonPayment({ type: "payment", amount: 5 })).toThrow( + HorizonValidationError + ); }); }); diff --git a/tests/unit/invoice-batch-publish.test.ts b/tests/unit/invoice-batch-publish.test.ts index fa73911..ed2879a 100644 --- a/tests/unit/invoice-batch-publish.test.ts +++ b/tests/unit/invoice-batch-publish.test.ts @@ -129,7 +129,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { stubInvoices([draftInvoice("a")]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) ).resolves.toMatchObject({ count: 1 }); }); }); @@ -147,7 +147,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { ]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a", "b", "c"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a", "b", "c"], sellerId: SELLER_ID }) ).rejects.toBeInstanceOf(ServiceError); expect(dataSource.transaction).not.toHaveBeenCalled(); @@ -159,7 +159,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { stubInvoices([good, draftInvoice("b", { ipfsHash: null })]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }) ).rejects.toBeInstanceOf(ServiceError); expect(good.status).toBe(InvoiceStatus.DRAFT); @@ -174,7 +174,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { }); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a", "b"], sellerId: SELLER_ID }) ).rejects.toThrow("deadlock detected"); expect(transactionCommitted).toBe(false); @@ -198,8 +198,9 @@ describe("InvoiceService.publishInvoicesBatch", () => { expect(error.code).toBe("batch_publish_rejected"); expect(error.statusCode).toBe(400); - const rejections = (error.details as { rejections: Array<{ invoiceId: string; code: string }> }) - .rejections; + const rejections = ( + error.details as { rejections: Array<{ invoiceId: string; code: string }> } + ).rejections; expect(rejections.map((r) => r.invoiceId).sort()).toEqual(["b", "c", "d"]); expect(rejections.find((r) => r.invoiceId === "b")?.code).toBe("invalid_status_transition"); expect(rejections.find((r) => r.invoiceId === "c")?.code).toBe("invoice_not_publishable"); @@ -213,8 +214,9 @@ describe("InvoiceService.publishInvoicesBatch", () => { .publishInvoicesBatch({ invoiceIds: ["a", "missing"], sellerId: SELLER_ID }) .catch((e) => e)) as ServiceError; - const rejections = (error.details as { rejections: Array<{ invoiceId: string; code: string }> }) - .rejections; + const rejections = ( + error.details as { rejections: Array<{ invoiceId: string; code: string }> } + ).rejections; expect(rejections).toEqual([ { invoiceId: "missing", code: "invoice_not_found", message: "Invoice not found" }, ]); @@ -252,17 +254,19 @@ describe("InvoiceService.publishInvoicesBatch", () => { describe("preconditions", () => { it("rejects an empty batch", async () => { await expect( - service.publishInvoicesBatch({ invoiceIds: [], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: [], sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "empty_batch", statusCode: 400 }); }); it("rejects the whole batch when the seller is not KYC approved", async () => { stubInvoices([ - draftInvoice("a", { seller: { ...approvedSeller(), kycStatus: KYCStatus.PENDING } as never }), + draftInvoice("a", { + seller: { ...approvedSeller(), kycStatus: KYCStatus.PENDING } as never, + }), ]); await expect( - service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + service.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "kyc_approval_required", statusCode: 403 }); expect(dataSource.transaction).not.toHaveBeenCalled(); @@ -276,7 +280,7 @@ describe("InvoiceService.publishInvoicesBatch", () => { stubInvoices([draftInvoice("a")]); await expect( - noDbService.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }), + noDbService.publishInvoicesBatch({ invoiceIds: ["a"], sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "batch_publish_unavailable", statusCode: 503 }); }); }); diff --git a/tests/unit/invoice-state-machine.test.ts b/tests/unit/invoice-state-machine.test.ts index cc5b72c..1cfd181 100644 --- a/tests/unit/invoice-state-machine.test.ts +++ b/tests/unit/invoice-state-machine.test.ts @@ -61,7 +61,9 @@ function createService(invoice: Invoice): InvoiceService { count: jest.fn().mockResolvedValue(0), create: jest.fn().mockImplementation((data: Partial) => data as Invoice), }; - const mockIPFS = { uploadFile: jest.fn() } as unknown as import("../../src/services/ipfs.service").IPFSService; + const mockIPFS = { + uploadFile: jest.fn(), + } as unknown as import("../../src/services/ipfs.service").IPFSService; return new InvoiceService({ invoiceRepository: mockRepo, ipfsService: mockIPFS }); } @@ -90,7 +92,7 @@ describe("Invoice state machine — invalid transitions blocked (issue #110)", ( const service = createService(invoice); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "invalid_status_transition", }); @@ -101,7 +103,7 @@ describe("Invoice state machine — invalid transitions blocked (issue #110)", ( const service = createService(invoice); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "invalid_status_transition", }); @@ -112,7 +114,7 @@ describe("Invoice state machine — invalid transitions blocked (issue #110)", ( const service = createService(invoice); await expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }) ).rejects.toMatchObject({ code: "invalid_status_transition", }); @@ -142,7 +144,7 @@ describe("Invoice state machine — invalid transitions blocked (issue #110)", ( const service = createService(invoice); return expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }) ).rejects.toBeInstanceOf(ServiceError); }); @@ -151,7 +153,7 @@ describe("Invoice state machine — invalid transitions blocked (issue #110)", ( const service = createService(invoice); return expect( - service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }), + service.publishInvoice({ invoiceId: invoice.id, sellerId: SELLER_ID }) ).rejects.toBeInstanceOf(ServiceError); }); }); diff --git a/tests/unit/ip-whitelist.middleware.test.ts b/tests/unit/ip-whitelist.middleware.test.ts index 5e93af4..7578531 100644 --- a/tests/unit/ip-whitelist.middleware.test.ts +++ b/tests/unit/ip-whitelist.middleware.test.ts @@ -163,4 +163,4 @@ describe("ipWhitelistMiddleware", () => { expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/kyc-admin-routes.test.ts b/tests/unit/kyc-admin-routes.test.ts index 00ddb4a..7a27bab 100644 --- a/tests/unit/kyc-admin-routes.test.ts +++ b/tests/unit/kyc-admin-routes.test.ts @@ -107,7 +107,9 @@ describe("KYC admin routes — structured logging", () => { }); it("falls back to the reviewer id when the reviewer has no user record", async () => { - stubUsers([{ id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.PENDING }]); + stubUsers([ + { id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP", kycStatus: KYCStatus.PENDING }, + ]); req.body = { userId: "user-1", reviewerId: "external-reviewer" }; await approveKYC(req, res, mockDataSource); diff --git a/tests/unit/marketplace-cursor-pagination.test.ts b/tests/unit/marketplace-cursor-pagination.test.ts index 3510461..50a76e7 100644 --- a/tests/unit/marketplace-cursor-pagination.test.ts +++ b/tests/unit/marketplace-cursor-pagination.test.ts @@ -70,7 +70,7 @@ describe("MarketplaceService.getPublishedInvoicesByCursor", () => { expect(repo.findPublishedInvoicesByCursor).toHaveBeenCalledWith( expect.objectContaining({ status: [InvoiceStatus.PUBLISHED] }), - expect.objectContaining({ sortField: "amount", order: "DESC", limit: 10, cursor: null }), + expect.objectContaining({ sortField: "amount", order: "DESC", limit: 10, cursor: null }) ); expect(result.data).toEqual([ @@ -101,7 +101,7 @@ describe("MarketplaceService.getPublishedInvoicesByCursor", () => { expect(repo.findPublishedInvoicesByCursor).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ limit: 100 }), + expect.objectContaining({ limit: 100 }) ); }); @@ -116,7 +116,7 @@ describe("MarketplaceService.getPublishedInvoicesByCursor", () => { expect(repo.findPublishedInvoicesByCursor).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ limit: 1 }), + expect.objectContaining({ limit: 1 }) ); }); @@ -142,7 +142,7 @@ describe("MarketplaceService.getPublishedInvoicesByCursor", () => { order: "ASC", limit: 20, cursor: "prior-cursor", - }), + }) ); }); @@ -154,7 +154,7 @@ describe("MarketplaceService.getPublishedInvoicesByCursor", () => { const legacyService = new MarketplaceService({ marketplaceRepository: legacyRepo }); await expect( - legacyService.getPublishedInvoicesByCursor({}, { sortField: "amount", limit: 10 }), + legacyService.getPublishedInvoicesByCursor({}, { sortField: "amount", limit: 10 }) ).rejects.toThrow(/does not implement findPublishedInvoicesByCursor/); }); }); @@ -192,7 +192,7 @@ describe("TypeORMMarketplaceRepository.findPublishedInvoicesByCursor (via create mockedPaginateQuery.mockClear(); await service.getPublishedInvoicesByCursor({}, { sortField, limit: 10 }); expect(mockedPaginateQuery).toHaveBeenCalledWith( - expect.objectContaining({ cursorField: expectedCursorField }), + expect.objectContaining({ cursorField: expectedCursorField }) ); } }); diff --git a/tests/unit/pagination.test.ts b/tests/unit/pagination.test.ts index b17afd1..b3fa497 100644 --- a/tests/unit/pagination.test.ts +++ b/tests/unit/pagination.test.ts @@ -65,7 +65,7 @@ describe("queryInvoicesPage", () => { expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( "(invoice.createdAt < :createdAt OR (invoice.createdAt = :createdAt AND invoice.id < :id))", - { createdAt: new Date("2024-01-01T00:00:00.000Z"), id: "invoice-5" }, + { createdAt: new Date("2024-01-01T00:00:00.000Z"), id: "invoice-5" } ); }); @@ -118,7 +118,7 @@ describe("queryInvoicesPage", () => { { sellerId: "seller-1", status: InvoiceStatus.FUNDED }, null, 10, - mockDataSource, + mockDataSource ); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith("invoice.sellerId = :sellerId", { @@ -136,7 +136,7 @@ describe("queryInvoicesPage", () => { { status: [InvoiceStatus.DRAFT, InvoiceStatus.PUBLISHED] }, null, 10, - mockDataSource, + mockDataSource ); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith("invoice.status IN (:...statuses)", { @@ -159,14 +159,20 @@ describe("queryInvoicesPage", () => { makeInvoice({ id: `invoice-${i + 1}`, createdAt: new Date(`2024-01-${String(10 - i).padStart(2, "0")}T00:00:00.000Z`), - }), + }) ); // Page 1: limit 5, returns invoices 1-5 (newest) mockQueryBuilder.getMany.mockResolvedValueOnce(allInvoices.slice(0, 6)); // 6 because take = limit+1 const page1 = await queryInvoicesPage({}, null, 5, mockDataSource); - expect(page1.data.map((i) => i.id)).toEqual(["invoice-1", "invoice-2", "invoice-3", "invoice-4", "invoice-5"]); + expect(page1.data.map((i) => i.id)).toEqual([ + "invoice-1", + "invoice-2", + "invoice-3", + "invoice-4", + "invoice-5", + ]); expect(page1.has_more).toBe(true); expect(page1.next_cursor).not.toBeNull(); @@ -184,7 +190,13 @@ describe("queryInvoicesPage", () => { const page2 = await queryInvoicesPage({}, page1.next_cursor, 5, mockDataSource); expect(page2.data).toHaveLength(5); - expect(page2.data.map((i) => i.id)).toEqual(["invoice-6", "invoice-7", "invoice-8", "invoice-9", "invoice-10"]); + expect(page2.data.map((i) => i.id)).toEqual([ + "invoice-6", + "invoice-7", + "invoice-8", + "invoice-9", + "invoice-10", + ]); expect(page2.has_more).toBe(false); // Newly inserted invoice does not appear in page 2 @@ -193,8 +205,16 @@ describe("queryInvoicesPage", () => { // No original invoice skipped between page 1 and page 2 const allPageIds = [...page1.data, ...page2.data].map((i) => i.id); expect(allPageIds).toEqual([ - "invoice-1", "invoice-2", "invoice-3", "invoice-4", "invoice-5", - "invoice-6", "invoice-7", "invoice-8", "invoice-9", "invoice-10", + "invoice-1", + "invoice-2", + "invoice-3", + "invoice-4", + "invoice-5", + "invoice-6", + "invoice-7", + "invoice-8", + "invoice-9", + "invoice-10", ]); // Combined pages cover all 10 original invoices exactly once (no duplicates) diff --git a/tests/unit/query-pagination.utils.test.ts b/tests/unit/query-pagination.utils.test.ts index 813d0a3..f152aeb 100644 --- a/tests/unit/query-pagination.utils.test.ts +++ b/tests/unit/query-pagination.utils.test.ts @@ -68,7 +68,9 @@ describe("paginateQuery", () => { it("uses a returned cursor to fetch the subsequent page, filtering strictly past it", async () => { const cursor = encodeQueryCursor("row.createdAt", new Date("2024-01-02T00:00:00.000Z")); - const qb = makeQueryBuilder([makeRow({ id: "3", createdAt: new Date("2024-01-01T00:00:00.000Z") })]); + const qb = makeQueryBuilder([ + makeRow({ id: "3", createdAt: new Date("2024-01-01T00:00:00.000Z") }), + ]); const result = await paginateQuery({ queryBuilder: qb, @@ -155,7 +157,7 @@ describe("paginateQuery", () => { cursorField: "row.createdAt", limit: 5, cursor, - }), + }) ).rejects.toThrow(/was encoded for field "row.score"/); }); @@ -168,7 +170,7 @@ describe("paginateQuery", () => { cursorField: "row.createdAt", limit: 5, cursor: "not-valid-base64-json!!", - }), + }) ).rejects.toThrow(/Invalid cursor/); }); @@ -176,11 +178,11 @@ describe("paginateQuery", () => { const qb = makeQueryBuilder([]); await expect( - paginateQuery({ queryBuilder: qb, cursorField: "row.createdAt", limit: 0 }), + paginateQuery({ queryBuilder: qb, cursorField: "row.createdAt", limit: 0 }) ).rejects.toThrow(/positive integer/); await expect( - paginateQuery({ queryBuilder: qb, cursorField: "row.createdAt", limit: 1.5 }), + paginateQuery({ queryBuilder: qb, cursorField: "row.createdAt", limit: 1.5 }) ).rejects.toThrow(/positive integer/); }); @@ -188,7 +190,7 @@ describe("paginateQuery", () => { const qb = makeQueryBuilder([]); await expect( - paginateQuery({ queryBuilder: qb, cursorField: "createdAt", limit: 5 }), + paginateQuery({ queryBuilder: qb, cursorField: "createdAt", limit: 5 }) ).rejects.toThrow(/Invalid cursorField/); }); @@ -258,7 +260,7 @@ describe("paginateQuery", () => { expect(qb.andWhere).toHaveBeenCalledWith( "(row.score < :cursor_row_score OR (row.score = :cursor_row_score AND row.id > :cursor_row_id))", - { cursor_row_score: 90, cursor_row_id: "2" }, + { cursor_row_score: 90, cursor_row_id: "2" } ); }); @@ -276,7 +278,7 @@ describe("paginateQuery", () => { expect(qb.andWhere).toHaveBeenCalledWith( "(row.score > :cursor_row_score OR (row.score = :cursor_row_score AND row.id > :cursor_row_id))", - { cursor_row_score: 90, cursor_row_id: "2" }, + { cursor_row_score: 90, cursor_row_id: "2" } ); }); }); @@ -295,7 +297,10 @@ describe("encodeQueryCursor / decodeQueryCursor", () => { it("round-trips a Date value as an ISO string", () => { const date = new Date("2024-06-15T12:00:00.000Z"); const cursor = encodeQueryCursor("row.createdAt", date); - expect(decodeQueryCursor(cursor)).toEqual({ field: "row.createdAt", value: date.toISOString() }); + expect(decodeQueryCursor(cursor)).toEqual({ + field: "row.createdAt", + value: date.toISOString(), + }); }); it("produces an opaque base64 string that does not leak plaintext field/value", () => { diff --git a/tests/unit/reconciliation-retry.test.ts b/tests/unit/reconciliation-retry.test.ts index eca6989..f883aa8 100644 --- a/tests/unit/reconciliation-retry.test.ts +++ b/tests/unit/reconciliation-retry.test.ts @@ -16,7 +16,7 @@ describe("classifyReconciliationError", () => { describe("transient provider failures are retryable", () => { it("classifies an explicit Horizon 5xx/429 provider error as retryable", () => { const decision = classifyReconciliationError( - new RetryableHorizonError("Transient Horizon response: 503"), + new RetryableHorizonError("Transient Horizon response: 503") ); expect(decision.retryable).toBe(true); @@ -28,8 +28,8 @@ describe("classifyReconciliationError", () => { new ServiceError( "horizon_unavailable", "Horizon is temporarily unavailable. Please retry later.", - 503, - ), + 503 + ) ); expect(decision).toMatchObject({ @@ -77,7 +77,7 @@ describe("classifyReconciliationError", () => { describe("permanent validation failures are NOT retryable", () => { it("classifies a malformed response error as permanent", () => { const decision = classifyReconciliationError( - new ServiceError("invalid_amount", "Invalid decimal amount: abc", 500), + new ServiceError("invalid_amount", "Invalid decimal amount: abc", 500) ); expect(decision.retryable).toBe(false); @@ -89,8 +89,8 @@ describe("classifyReconciliationError", () => { new ServiceError( "horizon_request_failed", "Horizon rejected the verification request.", - 502, - ), + 502 + ) ); expect(decision.retryable).toBe(false); @@ -99,11 +99,7 @@ describe("classifyReconciliationError", () => { it("classifies a business-rule invalid_payment error as permanent", () => { const decision = classifyReconciliationError( - new ServiceError( - "invalid_payment", - "No Stellar payment operation matched.", - 422, - ), + new ServiceError("invalid_payment", "No Stellar payment operation matched.", 422) ); expect(decision.retryable).toBe(false); @@ -112,7 +108,7 @@ describe("classifyReconciliationError", () => { it("classifies a not-found error as permanent", () => { const decision = classifyReconciliationError( - new ServiceError("transaction_not_found", "Transaction not found.", 404), + new ServiceError("transaction_not_found", "Transaction not found.", 404) ); expect(decision.retryable).toBe(false); @@ -121,11 +117,7 @@ describe("classifyReconciliationError", () => { it("classifies a reconciliation conflict as permanent", () => { const decision = classifyReconciliationError( - new ServiceError( - "reconciliation_conflict", - "Investment is already confirmed.", - 409, - ), + new ServiceError("reconciliation_conflict", "Investment is already confirmed.", 409) ); expect(decision.retryable).toBe(false); @@ -143,7 +135,7 @@ describe("classifyReconciliationError", () => { it("exposes the attempt number it was classified at", () => { const decision = classifyReconciliationError( new ServiceError("horizon_unavailable", "down", 503), - 3, + 3 ); expect(decision.attempt).toBe(3); @@ -175,11 +167,10 @@ describe("classifyReconciliationError", () => { const nonErrors: unknown[] = [undefined, null, "text", 42, { status: 200 }]; for (const value of nonErrors) { - const decision: ReconciliationRetryDecision = - classifyReconciliationError(value); + const decision: ReconciliationRetryDecision = classifyReconciliationError(value); expect(typeof decision.retryable).toBe("boolean"); expect(["transient_provider", "permanent_validation"]).toContain( - decision.kind as ReconciliationFailureKind, + decision.kind as ReconciliationFailureKind ); } }); diff --git a/tests/unit/response-envelope.test.ts b/tests/unit/response-envelope.test.ts index 8fda0d6..c0900e2 100644 --- a/tests/unit/response-envelope.test.ts +++ b/tests/unit/response-envelope.test.ts @@ -1,60 +1,60 @@ import { buildPaginatedResponse } from "../../src/utils/response-envelope.utils"; describe("buildPaginatedResponse", () => { - it("returns the correct envelope structure with items and meta", () => { - const items = [{ id: "1" }, { id: "2" }]; - const result = buildPaginatedResponse(items, 10, 2, "cursor-abc"); - - expect(result).toEqual({ - success: true, - data: items, - meta: { - total: 10, - limit: 2, - hasNextPage: true, - nextCursor: "cursor-abc", - }, - }); + it("returns the correct envelope structure with items and meta", () => { + const items = [{ id: "1" }, { id: "2" }]; + const result = buildPaginatedResponse(items, 10, 2, "cursor-abc"); + + expect(result).toEqual({ + success: true, + data: items, + meta: { + total: 10, + limit: 2, + hasNextPage: true, + nextCursor: "cursor-abc", + }, }); + }); - it("sets hasNextPage to false and nextCursor to null when no cursor is provided", () => { - const items = [{ id: "1" }]; - const result = buildPaginatedResponse(items, 1, 20); + it("sets hasNextPage to false and nextCursor to null when no cursor is provided", () => { + const items = [{ id: "1" }]; + const result = buildPaginatedResponse(items, 1, 20); - expect(result.meta.hasNextPage).toBe(false); - expect(result.meta.nextCursor).toBeNull(); - }); + expect(result.meta.hasNextPage).toBe(false); + expect(result.meta.nextCursor).toBeNull(); + }); - it("sets hasNextPage to false when nextCursor is an empty string", () => { - const items: number[] = []; - const result = buildPaginatedResponse(items, 0, 20, ""); + it("sets hasNextPage to false when nextCursor is an empty string", () => { + const items: number[] = []; + const result = buildPaginatedResponse(items, 0, 20, ""); - expect(result.meta.hasNextPage).toBe(false); - expect(result.meta.nextCursor).toBeNull(); - }); + expect(result.meta.hasNextPage).toBe(false); + expect(result.meta.nextCursor).toBeNull(); + }); - it("handles an empty items array", () => { - const result = buildPaginatedResponse([], 0, 20); + it("handles an empty items array", () => { + const result = buildPaginatedResponse([], 0, 20); - expect(result.data).toEqual([]); - expect(result.meta.total).toBe(0); - expect(result.meta.hasNextPage).toBe(false); - }); + expect(result.data).toEqual([]); + expect(result.meta.total).toBe(0); + expect(result.meta.hasNextPage).toBe(false); + }); - it("preserves the generic type of the items array", () => { - interface Invoice { - id: string; - amount: string; - } + it("preserves the generic type of the items array", () => { + interface Invoice { + id: string; + amount: string; + } - const invoices: Invoice[] = [ - { id: "inv-1", amount: "5000" }, - { id: "inv-2", amount: "10000" }, - ]; + const invoices: Invoice[] = [ + { id: "inv-1", amount: "5000" }, + { id: "inv-2", amount: "10000" }, + ]; - const result = buildPaginatedResponse(invoices, 2, 10); + const result = buildPaginatedResponse(invoices, 2, 10); - expect(result.data[0].amount).toBe("5000"); - expect(result.data[1].id).toBe("inv-2"); - }); -}); \ No newline at end of file + expect(result.data[0].amount).toBe("5000"); + expect(result.data[1].id).toBe("inv-2"); + }); +}); diff --git a/tests/unit/services/investment-yield.test.ts b/tests/unit/services/investment-yield.test.ts index a3dc4a6..d907e0b 100644 --- a/tests/unit/services/investment-yield.test.ts +++ b/tests/unit/services/investment-yield.test.ts @@ -3,14 +3,14 @@ import { computeInvestorReturn } from "../../../src/lib/investor-return"; function computeProRataDistribution( investorAmounts: bigint[], totalSettlementStroops: bigint, - platformFeeBps: number, + platformFeeBps: number ): { payouts: bigint[]; fee: bigint } { const totalFunded = investorAmounts.reduce((a, b) => a + b, 0n); const fee = (totalSettlementStroops * BigInt(platformFeeBps)) / 10_000n; const distributable = totalSettlementStroops - fee; const payouts = investorAmounts.map((amount) => - computeInvestorReturn(amount, totalFunded, distributable), + computeInvestorReturn(amount, totalFunded, distributable) ); return { payouts, fee }; @@ -23,11 +23,7 @@ describe("Investment pro-rata yield distribution", () => { const investorB = 4_000_000_000_000n; const settlement = 11_000_000_000_000n; - const { payouts, fee } = computeProRataDistribution( - [investorA, investorB], - settlement, - 0, - ); + const { payouts, fee } = computeProRataDistribution([investorA, investorB], settlement, 0); expect(fee).toBe(0n); expect(payouts[0] + payouts[1]).toBe(settlement); @@ -40,11 +36,7 @@ describe("Investment pro-rata yield distribution", () => { const investorB = 4_000_000_000_000n; const settlement = 11_000_000_000_000n; - const { payouts, fee } = computeProRataDistribution( - [investorA, investorB], - settlement, - 250, - ); + const { payouts, fee } = computeProRataDistribution([investorA, investorB], settlement, 250); const expectedFee = (11_000_000_000_000n * 250n) / 10_000n; expect(fee).toBe(expectedFee); @@ -57,7 +49,7 @@ describe("Investment pro-rata yield distribution", () => { const { payouts } = computeProRataDistribution( [5_000_000_000_000n, 5_000_000_000_000n], settlement, - 0, + 0 ); expect(payouts[0]).toBe(payouts[1]); @@ -73,7 +65,7 @@ describe("Investment pro-rata yield distribution", () => { const { payouts, fee } = computeProRataDistribution( [amount, amount, amount], settlement, - 100, + 100 ); const expectedFee = (settlement * 100n) / 10_000n; @@ -92,7 +84,7 @@ describe("Investment pro-rata yield distribution", () => { const { payouts, fee } = computeProRataDistribution( [investorA, investorB, investorC], settlement, - 250, + 250 ); const expectedFee = (settlement * 250n) / 10_000n; @@ -161,11 +153,7 @@ describe("Investment pro-rata yield distribution", () => { const amount = 1_000_000_000_000n; const settlement = 10_000_000_000_003n; - const { payouts } = computeProRataDistribution( - [amount, amount, amount], - settlement, - 0, - ); + const { payouts } = computeProRataDistribution([amount, amount, amount], settlement, 0); const totalPayout = payouts.reduce((a, b) => a + b, 0n); expect(totalPayout).toBeLessThanOrEqual(settlement); @@ -209,7 +197,11 @@ describe("Investment pro-rata yield distribution", () => { it("0% fee returns zero fee and full settlement as distributable", () => { const settlement = 10_000_000_000_000n; - const { fee } = computeProRataDistribution([5_000_000_000_000n, 5_000_000_000_000n], settlement, 0); + const { fee } = computeProRataDistribution( + [5_000_000_000_000n, 5_000_000_000_000n], + settlement, + 0 + ); expect(fee).toBe(0n); }); @@ -217,7 +209,11 @@ describe("Investment pro-rata yield distribution", () => { it("2.5% fee (250 bps) is calculated correctly", () => { const settlement = 100_000_000_000_000n; - const { fee } = computeProRataDistribution([50_000_000_000_000n, 50_000_000_000_000n], settlement, 250); + const { fee } = computeProRataDistribution( + [50_000_000_000_000n, 50_000_000_000_000n], + settlement, + 250 + ); expect(fee).toBe(2_500_000_000_000n); }); @@ -254,7 +250,7 @@ describe("Investment pro-rata yield distribution", () => { const result = computeInvestorReturn( 1_000_000_000_000n, 1_000_000_000_001n, - 1_000_000_000_000n, + 1_000_000_000_000n ); expect(typeof result).toBe("bigint"); expect(Number.isFinite(Number(result))).toBe(true); diff --git a/tests/unit/services/stellar/event-indexer.service.test.ts b/tests/unit/services/stellar/event-indexer.service.test.ts index 7405b0b..e6da425 100644 --- a/tests/unit/services/stellar/event-indexer.service.test.ts +++ b/tests/unit/services/stellar/event-indexer.service.test.ts @@ -49,7 +49,7 @@ describe("EventIndexerService (Issue #135)", () => { describe("Initialization", () => { it("should throw an error when contractIds array is empty", () => { expect(() => new EventIndexerService({ contractIds: [] })).toThrow( - "At least one contractId is required.", + "At least one contractId is required." ); }); @@ -130,7 +130,7 @@ describe("EventIndexerService (Issue #135)", () => { startLedger: 1999, limit: 50, filters: [{ type: "contract", contractIds: [ESCROW_CONTRACT_ID] }], - }), + }) ); }); }); @@ -161,7 +161,7 @@ describe("EventIndexerService (Issue #135)", () => { expect(mockEventLogRepo.save).toHaveBeenCalled(); expect(mockInvoiceRepo.findOne).toHaveBeenCalledWith({ where: { id: "INV-100" } }); expect(mockInvoiceRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ id: "INV-100", status: InvoiceStatus.PUBLISHED }), + expect.objectContaining({ id: "INV-100", status: InvoiceStatus.PUBLISHED }) ); }); @@ -189,7 +189,7 @@ describe("EventIndexerService (Issue #135)", () => { await service.ingestEvents([decodedEvent]); expect(mockInvoiceRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ id: "INV-100", status: InvoiceStatus.FUNDED }), + expect.objectContaining({ id: "INV-100", status: InvoiceStatus.FUNDED }) ); }); @@ -217,7 +217,7 @@ describe("EventIndexerService (Issue #135)", () => { await service.ingestEvents([decodedEvent]); expect(mockInvoiceRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ id: "INV-100", status: InvoiceStatus.SETTLED }), + expect.objectContaining({ id: "INV-100", status: InvoiceStatus.SETTLED }) ); }); @@ -245,7 +245,7 @@ describe("EventIndexerService (Issue #135)", () => { await service.ingestEvents([decodedEvent]); expect(mockInvoiceRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ id: "INV-100", status: InvoiceStatus.SETTLED }), + expect.objectContaining({ id: "INV-100", status: InvoiceStatus.SETTLED }) ); }); }); @@ -275,7 +275,7 @@ describe("EventIndexerService (Issue #135)", () => { service.stop(); expect(mockLogger.info).toHaveBeenCalledWith( "Starting Soroban event indexer service", - expect.any(Object), + expect.any(Object) ); expect(mockLogger.info).toHaveBeenCalledWith("Stopped Soroban event indexer service"); }); diff --git a/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts b/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts index 37a8929..dbe1e80 100644 --- a/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts +++ b/tests/unit/services/stellar/invoice-escrow-contract.service.test.ts @@ -35,16 +35,14 @@ describe("InvoiceEscrowContractService", () => { }); it("should initialize correctly with string contract ID", () => { - const stringInitService = new InvoiceEscrowContractService( - ESCROW_CONTRACT_ID, - mockLogger, - ); + const stringInitService = new InvoiceEscrowContractService(ESCROW_CONTRACT_ID, mockLogger); expect(stringInitService.contractId).toBe(ESCROW_CONTRACT_ID); }); it("should throw error if contractId is empty", () => { - expect(() => new InvoiceEscrowContractService("")).toThrow( - "contractId is required.", + expect(() => new InvoiceEscrowContractService("")).toThrow("contractId is required."); + expect(() => new InvoiceEscrowContractService({ contractId: "" })).toThrow( + "contractId is required." ); expect( () => new InvoiceEscrowContractService({ contractId: "" }), @@ -62,15 +60,11 @@ describe("InvoiceEscrowContractService", () => { TEST_SELLER, TEST_AMOUNT_STROOPS, TEST_DUE_DATE, - TEST_TOKEN, + TEST_TOKEN ); expect(op.body().switch().name).toBe("invokeHostFunction"); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("create_escrow"); @@ -144,7 +138,7 @@ describe("InvoiceEscrowContractService", () => { sorobanContractId: ESCROW_CONTRACT_ID, sellerAddress: TEST_SELLER, amountStroops: TEST_AMOUNT_STROOPS.toString(), - }, + } ); }); @@ -179,11 +173,7 @@ describe("InvoiceEscrowContractService", () => { it("should construct valid fund_escrow host function invocation", () => { const op = service.buildFundEscrowTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS); expect(op.body().switch().name).toBe("invokeHostFunction"); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("fund_escrow"); const args = invokeContractArgs.args(); @@ -209,11 +199,7 @@ describe("InvoiceEscrowContractService", () => { describe("buildRecordPaymentTx", () => { it("should construct valid record_payment host function invocation", () => { const op = service.buildRecordPaymentTx(TEST_INVOICE_ID, TEST_SELLER, TEST_AMOUNT_STROOPS); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("record_payment"); const args = invokeContractArgs.args(); @@ -237,11 +223,7 @@ describe("InvoiceEscrowContractService", () => { describe("buildSettleEscrowTx", () => { it("should construct valid settle_escrow host function invocation", () => { const op = service.buildSettleEscrowTx(TEST_INVOICE_ID); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("settle_escrow"); const args = invokeContractArgs.args(); diff --git a/tests/unit/services/stellar/invoice-token-tx.test.ts b/tests/unit/services/stellar/invoice-token-tx.test.ts index a8a9309..4ca36c3 100644 --- a/tests/unit/services/stellar/invoice-token-tx.test.ts +++ b/tests/unit/services/stellar/invoice-token-tx.test.ts @@ -16,9 +16,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { }); it("should throw an error when contractId is empty", () => { - expect(() => new InvoiceTokenContractService("")).toThrow( - "contractId is required.", - ); + expect(() => new InvoiceTokenContractService("")).toThrow("contractId is required."); }); it("should construct a valid InvokeHostFunction operation", () => { @@ -37,9 +35,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { const invokeHostFunctionOp = op.body().invokeHostFunctionOp(); const hostFunction = invokeHostFunctionOp.hostFunction(); - expect(hostFunction.switch().name).toBe( - "hostFunctionTypeInvokeContract", - ); + expect(hostFunction.switch().name).toBe("hostFunctionTypeInvokeContract"); const invokeContractArgs = hostFunction.invokeContract(); const contractAddressScVal = invokeContractArgs.contractAddress(); @@ -52,11 +48,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { const tokenAmount = 750_000n; const op = service.buildMintTx(TEST_RECIPIENT, tokenAmount); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); // Verify function name const functionName = invokeContractArgs.functionName().toString(); @@ -83,12 +75,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { const amountFromString = service.buildMintTx(TEST_RECIPIENT, "1000000"); const getAmount = (operation: xdr.Operation) => { - const args = operation - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract() - .args(); + const args = operation.body().invokeHostFunctionOp().hostFunction().invokeContract().args(); return BigInt(scValToNative(args[1])); }; @@ -105,12 +92,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { const opLarge = service.buildMintTx(TEST_RECIPIENT, largeAmount); const getAmount = (operation: xdr.Operation) => { - const args = operation - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract() - .args(); + const args = operation.body().invokeHostFunctionOp().hostFunction().invokeContract().args(); return BigInt(scValToNative(args[1])); }; @@ -124,11 +106,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { describe("mintInvoiceTokens", () => { it("should build and return structured mint result", async () => { - const result = await service.mintInvoiceTokens( - "INV-123", - TEST_RECIPIENT, - 500_000n, - ); + const result = await service.mintInvoiceTokens("INV-123", TEST_RECIPIENT, 500_000n); expect(result.invoiceId).toBe("INV-123"); expect(result.contractId).toBe(TOKEN_CONTRACT_ID); @@ -141,11 +119,7 @@ describe("InvoiceTokenContractService - buildMintTx", () => { describe("buildBalanceTx & getTokenBalance", () => { it("should construct valid balance host function invocation", () => { const op = service.buildBalanceTx(TEST_RECIPIENT); - const invokeContractArgs = op - .body() - .invokeHostFunctionOp() - .hostFunction() - .invokeContract(); + const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); expect(invokeContractArgs.functionName().toString()).toBe("balance"); const args = invokeContractArgs.args(); diff --git a/tests/unit/services/stellar/payment-distributor-settlement.test.ts b/tests/unit/services/stellar/payment-distributor-settlement.test.ts index e732e04..7c8bb80 100644 --- a/tests/unit/services/stellar/payment-distributor-settlement.test.ts +++ b/tests/unit/services/stellar/payment-distributor-settlement.test.ts @@ -18,29 +18,60 @@ describe("PaymentDistributorContractService settlement execution", () => { it("submits and waits for successful ledger confirmation", async () => { const service = new PaymentDistributorContractService({ - contractId, server: createRpc(), networkPassphrase: "Test SDF Network ; September 2015", - platformSecretKey: signer.secret(), verifyDistributorWiring: async () => true, - confirmationPollMs: 0, confirmationAttempts: 2, + contractId, + server: createRpc(), + networkPassphrase: "Test SDF Network ; September 2015", + platformSecretKey: signer.secret(), + verifyDistributorWiring: async () => true, + confirmationPollMs: 0, + confirmationAttempts: 2, }); - await expect(service.distributePayouts({ - invoiceId: "INV-1", totalAmountStroops: 1_000n, feeRecipient, feeBps: 250, - recipients: [{ address: recipient, amountStroops: 975n }], - })).resolves.toEqual({ transactionHash: "tx-hash", ledger: 77 }); + await expect( + service.distributePayouts({ + invoiceId: "INV-1", + totalAmountStroops: 1_000n, + feeRecipient, + feeBps: 250, + recipients: [{ address: recipient, amountStroops: 975n }], + }) + ).resolves.toEqual({ transactionHash: "tx-hash", ledger: 77 }); }); it("stops without settlement when wiring is not initialized", async () => { const service = new PaymentDistributorContractService({ - contractId, server: createRpc(), networkPassphrase: "Test SDF Network ; September 2015", - platformSecretKey: signer.secret(), verifyDistributorWiring: async () => false, + contractId, + server: createRpc(), + networkPassphrase: "Test SDF Network ; September 2015", + platformSecretKey: signer.secret(), + verifyDistributorWiring: async () => false, }); - await expect(service.distributePayouts({ invoiceId: "INV-1", totalAmountStroops: 1_000n, feeRecipient, feeBps: 250, recipients: [{ address: recipient, amountStroops: 975n }] })).rejects.toThrow("not initialized"); + await expect( + service.distributePayouts({ + invoiceId: "INV-1", + totalAmountStroops: 1_000n, + feeRecipient, + feeBps: 250, + recipients: [{ address: recipient, amountStroops: 975n }], + }) + ).rejects.toThrow("not initialized"); }); it("surfaces a reverted transaction", async () => { const service = new PaymentDistributorContractService({ - contractId, server: createRpc("FAILED"), networkPassphrase: "Test SDF Network ; September 2015", - platformSecretKey: signer.secret(), confirmationPollMs: 0, + contractId, + server: createRpc("FAILED"), + networkPassphrase: "Test SDF Network ; September 2015", + platformSecretKey: signer.secret(), + confirmationPollMs: 0, }); - await expect(service.distributePayouts({ invoiceId: "INV-1", totalAmountStroops: 1_000n, feeRecipient, feeBps: 250, recipients: [{ address: recipient, amountStroops: 975n }] })).rejects.toThrow("reverted"); + await expect( + service.distributePayouts({ + invoiceId: "INV-1", + totalAmountStroops: 1_000n, + feeRecipient, + feeBps: 250, + recipients: [{ address: recipient, amountStroops: 975n }], + }) + ).rejects.toThrow("reverted"); }); }); diff --git a/tests/unit/services/stellar/payment-distributor-tx.test.ts b/tests/unit/services/stellar/payment-distributor-tx.test.ts index e6d78f1..fce292c 100644 --- a/tests/unit/services/stellar/payment-distributor-tx.test.ts +++ b/tests/unit/services/stellar/payment-distributor-tx.test.ts @@ -7,7 +7,8 @@ import { describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156)", () => { // A known-valid contract StrKey, reused from the existing // invoice-escrow-contract.service.test.ts fixtures. - const PAYMENT_DISTRIBUTOR_CONTRACT_ID = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + const PAYMENT_DISTRIBUTOR_CONTRACT_ID = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; const SELLER_ADDRESS = Keypair.random().publicKey(); const INVESTOR_A_ADDRESS = Keypair.random().publicKey(); @@ -38,11 +39,13 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, - 250, + 250 ); const invokeContractArgs = op.body().invokeHostFunctionOp().hostFunction().invokeContract(); - const invokedContractId = Address.fromScAddress(invokeContractArgs.contractAddress()).toString(); + const invokedContractId = Address.fromScAddress( + invokeContractArgs.contractAddress() + ).toString(); expect(invokedContractId).toBe(PAYMENT_DISTRIBUTOR_CONTRACT_ID); expect(invokedContractId).toBe(service.contractId); @@ -53,7 +56,7 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, - 250, + 250 ); const functionName = op @@ -112,7 +115,7 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, - 250, + 250 ); const args = op.body().invokeHostFunctionOp().hostFunction().invokeContract().args(); @@ -127,7 +130,7 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, - feeBps, + feeBps ); const args = op.body().invokeHostFunctionOp().hostFunction().invokeContract().args(); expect(scValToNative(args[4])).toBe(feeBps); @@ -135,19 +138,19 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 it("rejects feeBps above 10000 (100%)", () => { expect(() => - service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, 10_001), + service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, 10_001) ).toThrow("feeBps must be an integer between 0 and 10000."); }); it("rejects a negative feeBps", () => { expect(() => - service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, -1), + service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, -1) ).toThrow("feeBps must be an integer between 0 and 10000."); }); it("rejects a non-integer feeBps", () => { expect(() => - service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, 12.5), + service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, PLATFORM_FEE_ACCOUNT, 12.5) ).toThrow("feeBps must be an integer between 0 and 10000."); }); }); @@ -155,7 +158,7 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 describe("invalid inputs", () => { it("throws when recipients is empty", () => { expect(() => - service.buildDistributePayoutsTx(INVOICE_ID, [], PLATFORM_FEE_ACCOUNT, 250), + service.buildDistributePayoutsTx(INVOICE_ID, [], PLATFORM_FEE_ACCOUNT, 250) ).toThrow("At least one payout recipient is required."); }); @@ -165,13 +168,13 @@ describe("PaymentDistributorContractService.buildDistributePayoutsTx (Issue #156 ]; expect(() => - service.buildDistributePayoutsTx(INVOICE_ID, recipients, PLATFORM_FEE_ACCOUNT, 250), + service.buildDistributePayoutsTx(INVOICE_ID, recipients, PLATFORM_FEE_ACCOUNT, 250) ).toThrow(); }); it("throws when the platform fee account is invalid", () => { expect(() => - service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, "not-a-valid-address", 250), + service.buildDistributePayoutsTx(INVOICE_ID, singleRecipient, "not-a-valid-address", 250) ).toThrow(); }); }); diff --git a/tests/unit/stellar-address.test.ts b/tests/unit/stellar-address.test.ts index b9a5676..e897dde 100644 --- a/tests/unit/stellar-address.test.ts +++ b/tests/unit/stellar-address.test.ts @@ -10,9 +10,7 @@ describe("isValidStellarPublicKey", () => { it("returns false for malformed G-addresses", () => { expect(isValidStellarPublicKey("GABC")).toBe(false); expect( - isValidStellarPublicKey( - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - ), + isValidStellarPublicKey("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") ).toBe(false); }); diff --git a/tests/unit/stellar-challenge.test.ts b/tests/unit/stellar-challenge.test.ts index 744a335..bad2fcd 100644 --- a/tests/unit/stellar-challenge.test.ts +++ b/tests/unit/stellar-challenge.test.ts @@ -12,7 +12,7 @@ describe("buildWalletChallenge", () => { const { transaction, nonce } = buildWalletChallenge( walletKeypair.publicKey(), TESTNET_PASSPHRASE, - serverKeypair, + serverKeypair ); expect(transaction).toBeInstanceOf(Transaction); @@ -22,7 +22,9 @@ describe("buildWalletChallenge", () => { // Transaction must carry at least one ManageData operation named web_auth_domain const ops = transaction.operations; expect(ops.length).toBeGreaterThan(0); - const managedDataOp = ops.find((op) => op.type === "manageData" && op.name === "web_auth_domain"); + const managedDataOp = ops.find( + (op) => op.type === "manageData" && op.name === "web_auth_domain" + ); expect(managedDataOp).toBeDefined(); // Transaction must be signed by the server keypair @@ -33,20 +35,26 @@ describe("buildWalletChallenge", () => { const serverKeypair = Keypair.random(); expect(() => - buildWalletChallenge("NOT_A_VALID_STELLAR_ADDRESS", TESTNET_PASSPHRASE, serverKeypair), + buildWalletChallenge("NOT_A_VALID_STELLAR_ADDRESS", TESTNET_PASSPHRASE, serverKeypair) ).toThrow(HttpError); - expect(() => - buildWalletChallenge("", TESTNET_PASSPHRASE, serverKeypair), - ).toThrow(HttpError); + expect(() => buildWalletChallenge("", TESTNET_PASSPHRASE, serverKeypair)).toThrow(HttpError); }); it("produces a different nonce on each call", () => { const walletKeypair = Keypair.random(); const serverKeypair = Keypair.random(); - const first = buildWalletChallenge(walletKeypair.publicKey(), TESTNET_PASSPHRASE, serverKeypair); - const second = buildWalletChallenge(walletKeypair.publicKey(), TESTNET_PASSPHRASE, serverKeypair); + const first = buildWalletChallenge( + walletKeypair.publicKey(), + TESTNET_PASSPHRASE, + serverKeypair + ); + const second = buildWalletChallenge( + walletKeypair.publicKey(), + TESTNET_PASSPHRASE, + serverKeypair + ); expect(first.nonce).not.toBe(second.nonce); expect(first.transaction.toXDR()).not.toBe(second.transaction.toXDR()); diff --git a/tests/unit/unit-conversion.test.ts b/tests/unit/unit-conversion.test.ts index de94274..b46b2d0 100644 --- a/tests/unit/unit-conversion.test.ts +++ b/tests/unit/unit-conversion.test.ts @@ -2,112 +2,104 @@ import Decimal from "decimal.js"; import { stroopsToXlm, xlmToStroops } from "../../src/utils/unit-conversion.utils"; describe("stroopsToXlm", () => { - it("converts 10,000,000 stroops to 1.0000000 XLM", () => { - expect(stroopsToXlm(10_000_000n)).toBe("1.0000000"); - }); - - it("converts 1 stroop to 0.0000001 XLM", () => { - expect(stroopsToXlm(1n)).toBe("0.0000001"); - }); - - it("converts 0 stroops to 0.0000000 XLM", () => { - expect(stroopsToXlm(0n)).toBe("0.0000000"); - }); - - it("converts 100,000 XLM worth of stroops without floating point error", () => { - // 100,000 XLM = 1,000,000,000,000 stroops - const stroops = 100_000n * 10_000_000n; - expect(stroopsToXlm(stroops)).toBe("100000.0000000"); - }); - - it("handles large values above MAX_SAFE_INTEGER without precision loss", () => { - // Number.MAX_SAFE_INTEGER (9007199254740991) whole XLM, in stroops - const stroops = 9_007_199_254_740_991n * 10_000_000n; - expect(stroopsToXlm(stroops)).toBe("9007199254740991.0000000"); - }); - - it("handles string input for bigint", () => { - expect(stroopsToXlm("10000000")).toBe("1.0000000"); - expect(stroopsToXlm("1")).toBe("0.0000001"); - }); - - it("handles fractional stroop amounts correctly (truncation)", () => { - // Fractional stroop concept: 1 stroop is the minimum unit - // So 0.5 stroop doesn't exist, but we test boundary - expect(stroopsToXlm(0n)).toBe("0.0000000"); - }); + it("converts 10,000,000 stroops to 1.0000000 XLM", () => { + expect(stroopsToXlm(10_000_000n)).toBe("1.0000000"); + }); + + it("converts 1 stroop to 0.0000001 XLM", () => { + expect(stroopsToXlm(1n)).toBe("0.0000001"); + }); + + it("converts 0 stroops to 0.0000000 XLM", () => { + expect(stroopsToXlm(0n)).toBe("0.0000000"); + }); + + it("converts 100,000 XLM worth of stroops without floating point error", () => { + // 100,000 XLM = 1,000,000,000,000 stroops + const stroops = 100_000n * 10_000_000n; + expect(stroopsToXlm(stroops)).toBe("100000.0000000"); + }); + + it("handles large values above MAX_SAFE_INTEGER without precision loss", () => { + // Number.MAX_SAFE_INTEGER (9007199254740991) whole XLM, in stroops + const stroops = 9_007_199_254_740_991n * 10_000_000n; + expect(stroopsToXlm(stroops)).toBe("9007199254740991.0000000"); + }); + + it("handles string input for bigint", () => { + expect(stroopsToXlm("10000000")).toBe("1.0000000"); + expect(stroopsToXlm("1")).toBe("0.0000001"); + }); + + it("handles fractional stroop amounts correctly (truncation)", () => { + // Fractional stroop concept: 1 stroop is the minimum unit + // So 0.5 stroop doesn't exist, but we test boundary + expect(stroopsToXlm(0n)).toBe("0.0000000"); + }); }); describe("xlmToStroops", () => { - it("converts 1 XLM to 10,000,000 stroops", () => { - expect(xlmToStroops("1.0000000")).toBe(10_000_000n); - }); - - it("converts 0.0000001 XLM to 1 stroop", () => { - expect(xlmToStroops("0.0000001")).toBe(1n); - }); - - it("converts 0 XLM to 0 stroops", () => { - expect(xlmToStroops("0")).toBe(0n); - }); - - it("converts 100,000 XLM to stroops correctly", () => { - expect(xlmToStroops("100000")).toBe(1_000_000_000_000n); - }); - - it("converts string input with decimals correctly", () => { - expect(xlmToStroops("1.5")).toBe(15_000_000n); - expect(xlmToStroops("0.5")).toBe(5_000_000n); - }); - - it("converts number input correctly", () => { - expect(xlmToStroops(1)).toBe(10_000_000n); - expect(xlmToStroops(0)).toBe(0n); - }); - - it("rounds down fractional stroops to the nearest stroop", () => { - // 0.00000015 XLM would be 1.5 stroops, should round down to 1 - expect(xlmToStroops("0.00000015")).toBe(1n); - }); + it("converts 1 XLM to 10,000,000 stroops", () => { + expect(xlmToStroops("1.0000000")).toBe(10_000_000n); + }); + + it("converts 0.0000001 XLM to 1 stroop", () => { + expect(xlmToStroops("0.0000001")).toBe(1n); + }); + + it("converts 0 XLM to 0 stroops", () => { + expect(xlmToStroops("0")).toBe(0n); + }); + + it("converts 100,000 XLM to stroops correctly", () => { + expect(xlmToStroops("100000")).toBe(1_000_000_000_000n); + }); + + it("converts string input with decimals correctly", () => { + expect(xlmToStroops("1.5")).toBe(15_000_000n); + expect(xlmToStroops("0.5")).toBe(5_000_000n); + }); + + it("converts number input correctly", () => { + expect(xlmToStroops(1)).toBe(10_000_000n); + expect(xlmToStroops(0)).toBe(0n); + }); + + it("rounds down fractional stroops to the nearest stroop", () => { + // 0.00000015 XLM would be 1.5 stroops, should round down to 1 + expect(xlmToStroops("0.00000015")).toBe(1n); + }); }); describe("stroopsToXlm / xlmToStroops round-trip", () => { - it("round-trips correctly for whole XLM amounts", () => { - const xlmAmounts = ["0", "1", "100", "10000", "100000"]; - - for (const xlm of xlmAmounts) { - const stroops = xlmToStroops(xlm); - const backToXlm = stroopsToXlm(stroops); - expect(backToXlm).toBe(new Decimal(xlm).toFixed(7)); - } - }); - - it("round-trips correctly for common stroop amounts", () => { - const stroopAmounts = [0n, 1n, 10n, 100n, 1000n, 10_000_000n, 1_000_000_000_000n]; - - for (const stroops of stroopAmounts) { - const xlm = stroopsToXlm(stroops); - const backToStroops = xlmToStroops(xlm); - expect(backToStroops).toBe(stroops); - } - }); - - it("round-trip is accurate for values that could be affected by floating point", () => { - // These values are problematic with regular JS floating point math - const testValues = [ - "0.1", - "0.2", - "0.3", - "0.7", - "1.0000001", - "999999.9999999", - "1234.5678901", - ]; - - for (const xlm of testValues) { - const stroops = xlmToStroops(xlm); - const backToXlm = stroopsToXlm(stroops); - expect(backToXlm).toBe(new Decimal(xlm).toFixed(7)); - } - }); -}); \ No newline at end of file + it("round-trips correctly for whole XLM amounts", () => { + const xlmAmounts = ["0", "1", "100", "10000", "100000"]; + + for (const xlm of xlmAmounts) { + const stroops = xlmToStroops(xlm); + const backToXlm = stroopsToXlm(stroops); + expect(backToXlm).toBe(new Decimal(xlm).toFixed(7)); + } + }); + + it("round-trips correctly for common stroop amounts", () => { + const stroopAmounts = [0n, 1n, 10n, 100n, 1000n, 10_000_000n, 1_000_000_000_000n]; + + for (const stroops of stroopAmounts) { + const xlm = stroopsToXlm(stroops); + const backToStroops = xlmToStroops(xlm); + expect(backToStroops).toBe(stroops); + } + }); + + it("round-trip is accurate for values that could be affected by floating point", () => { + // These values are problematic with regular JS floating point math + const testValues = ["0.1", "0.2", "0.3", "0.7", "1.0000001", "999999.9999999", "1234.5678901"]; + + for (const xlm of testValues) { + const stroops = xlmToStroops(xlm); + const backToXlm = stroopsToXlm(stroops); + expect(backToXlm).toBe(new Decimal(xlm).toFixed(7)); + } + }); +}); diff --git a/tests/unit/utils/discount-calculator.utils.test.ts b/tests/unit/utils/discount-calculator.utils.test.ts index 542ea43..c118de1 100644 --- a/tests/unit/utils/discount-calculator.utils.test.ts +++ b/tests/unit/utils/discount-calculator.utils.test.ts @@ -83,7 +83,7 @@ describe("discount-calculator.utils", () => { faceValue: "-100", dueDate: new Date(), discountBps: 100, - }), + }) ).toThrow("Face value must be a positive number greater than zero"); expect(() => @@ -91,7 +91,7 @@ describe("discount-calculator.utils", () => { faceValue: "1000", dueDate: new Date(), discountBps: 12000, - }), + }) ).toThrow("Discount BPS must be between 0 and 10,000"); }); }); diff --git a/tests/unit/utils/invoice-state-machine.test.ts b/tests/unit/utils/invoice-state-machine.test.ts index 15c9ee8..67c28e3 100644 --- a/tests/unit/utils/invoice-state-machine.test.ts +++ b/tests/unit/utils/invoice-state-machine.test.ts @@ -22,15 +22,11 @@ describe("isValidInvoiceStateTransition", () => { }); it("rejects transition from DRAFT to SETTLED", () => { - expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.SETTLED)).toBe( - false - ); + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.SETTLED)).toBe(false); }); it("rejects transition from DRAFT to PENDING", () => { - expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.PENDING)).toBe( - false - ); + expect(isValidInvoiceStateTransition(InvoiceStatus.DRAFT, InvoiceStatus.PENDING)).toBe(false); }); it("rejects transition from DRAFT to DRAFT", () => { @@ -52,9 +48,7 @@ describe("isValidInvoiceStateTransition", () => { }); it("rejects transition from PENDING to DRAFT", () => { - expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.DRAFT)).toBe( - false - ); + expect(isValidInvoiceStateTransition(InvoiceStatus.PENDING, InvoiceStatus.DRAFT)).toBe(false); }); it("rejects transition from PENDING to FUNDED", () => { @@ -98,9 +92,7 @@ describe("isValidInvoiceStateTransition", () => { describe("valid transitions from FUNDED", () => { it("allows transition from FUNDED to SETTLED", () => { - expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.SETTLED)).toBe( - true - ); + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.SETTLED)).toBe(true); }); it("allows transition from FUNDED to CANCELLED", () => { @@ -134,9 +126,7 @@ describe("isValidInvoiceStateTransition", () => { }); it("rejects transition from SETTLED to DRAFT", () => { - expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.DRAFT)).toBe( - false - ); + expect(isValidInvoiceStateTransition(InvoiceStatus.SETTLED, InvoiceStatus.DRAFT)).toBe(false); }); it("rejects transition from SETTLED to PENDING", () => { @@ -167,9 +157,7 @@ describe("isValidInvoiceStateTransition", () => { describe("valid transitions from CANCELLED", () => { it("rejects all transitions from CANCELLED", () => { allStatuses.forEach((status) => { - expect( - isValidInvoiceStateTransition(InvoiceStatus.CANCELLED, status) - ).toBe(false); + expect(isValidInvoiceStateTransition(InvoiceStatus.CANCELLED, status)).toBe(false); }); }); }); @@ -188,9 +176,7 @@ describe("isValidInvoiceStateTransition", () => { }); it("rejects transition from FUNDED to FUNDED", () => { - expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.FUNDED)).toBe( - false - ); + expect(isValidInvoiceStateTransition(InvoiceStatus.FUNDED, InvoiceStatus.FUNDED)).toBe(false); }); it("rejects transition from CANCELLED to CANCELLED", () => { diff --git a/tests/unit/utils/stellar-address.test.ts b/tests/unit/utils/stellar-address.test.ts index 196b7ea..ab21b2a 100644 --- a/tests/unit/utils/stellar-address.test.ts +++ b/tests/unit/utils/stellar-address.test.ts @@ -1,4 +1,7 @@ -import { isValidStellarPublicKey, isValidSorobanContractId } from "../../../src/utils/stellar-address.utils"; +import { + isValidStellarPublicKey, + isValidSorobanContractId, +} from "../../../src/utils/stellar-address.utils"; describe("stellar-address utils", () => { describe("isValidStellarPublicKey", () => { @@ -48,9 +51,9 @@ describe("stellar-address utils", () => { it("rejects malformed contract addresses", () => { expect(isValidSorobanContractId("C" + "0".repeat(55))).toBe(false); - expect(isValidSorobanContractId("CAX62CGE4JWSCDDO6NFUTC2V7VWGX6VNWC6EIB2BPKFYI2YEO5TV5WU")).toBe( - false - ); + expect( + isValidSorobanContractId("CAX62CGE4JWSCDDO6NFUTC2V7VWGX6VNWC6EIB2BPKFYI2YEO5TV5WU") + ).toBe(false); }); }); }); diff --git a/tests/unit/webhook-signature-headers.test.ts b/tests/unit/webhook-signature-headers.test.ts index cabe77f..bf15ef9 100644 --- a/tests/unit/webhook-signature-headers.test.ts +++ b/tests/unit/webhook-signature-headers.test.ts @@ -73,7 +73,7 @@ describe("verifyWebhookSignatureHeaders", () => { timestamp: String(NOW_MS), secret: SECRET, now, - }), + }) ).toBe(String(NOW_MS)); }); }); @@ -89,7 +89,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "INVALID_SIGNATURE", + "INVALID_SIGNATURE" ); }); @@ -105,12 +105,16 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "INVALID_SIGNATURE", + "INVALID_SIGNATURE" ); }); it("rejects a signature produced with the wrong secret", () => { - const wrongSig = computeWebhookSignatureForTimestamp(PAYLOAD, String(NOW_MS), "different-secret"); + const wrongSig = computeWebhookSignatureForTimestamp( + PAYLOAD, + String(NOW_MS), + "different-secret" + ); expectWebhookError( () => @@ -121,7 +125,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "INVALID_SIGNATURE", + "INVALID_SIGNATURE" ); }); @@ -137,7 +141,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "INVALID_SIGNATURE", + "INVALID_SIGNATURE" ); }); }); @@ -153,7 +157,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "MISSING_SIGNATURE", + "MISSING_SIGNATURE" ); }); @@ -167,7 +171,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "MISSING_SIGNATURE", + "MISSING_SIGNATURE" ); }); @@ -182,7 +186,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "MISSING_SIGNATURE", + "MISSING_SIGNATURE" ); } }); @@ -198,7 +202,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "MISSING_TIMESTAMP", + "MISSING_TIMESTAMP" ); } }); @@ -215,7 +219,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "MALFORMED_SIGNATURE", + "MALFORMED_SIGNATURE" ); }); @@ -229,7 +233,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "MALFORMED_SIGNATURE", + "MALFORMED_SIGNATURE" ); }); @@ -243,7 +247,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "TIMESTAMP_OUT_OF_RANGE", + "TIMESTAMP_OUT_OF_RANGE" ); }); @@ -259,7 +263,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "TIMESTAMP_SKEWED", + "TIMESTAMP_SKEWED" ); }); @@ -275,7 +279,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, now, }), - "TIMESTAMP_SKEWED", + "TIMESTAMP_SKEWED" ); }); }); @@ -292,7 +296,7 @@ describe("verifyWebhookSignatureHeaders", () => { secret: SECRET, maxTimestampSkewMs: 5_000, now, - }), + }) ).not.toThrow(); const outsideWindow = NOW_MS - 10_000; @@ -307,7 +311,7 @@ describe("verifyWebhookSignatureHeaders", () => { maxTimestampSkewMs: 5_000, now, }), - "TIMESTAMP_SKEWED", + "TIMESTAMP_SKEWED" ); }); }); diff --git a/tests/unit/webhook-signature.test.ts b/tests/unit/webhook-signature.test.ts index 29571f9..f009654 100644 --- a/tests/unit/webhook-signature.test.ts +++ b/tests/unit/webhook-signature.test.ts @@ -1,7 +1,4 @@ -import { - computeWebhookSignature, - verifyWebhookSignature, -} from "../../src/utils/webhook-signature"; +import { computeWebhookSignature, verifyWebhookSignature } from "../../src/utils/webhook-signature"; describe("verifyWebhookSignature", () => { const SECRET_A = "whsec_test_secret_a_12345"; @@ -29,9 +26,13 @@ describe("verifyWebhookSignature", () => { }); it("should return false for empty inputs", () => { - expect(verifyWebhookSignature("", computeWebhookSignature(PAYLOAD, SECRET_A), SECRET_A)).toBe(false); + expect(verifyWebhookSignature("", computeWebhookSignature(PAYLOAD, SECRET_A), SECRET_A)).toBe( + false + ); expect(verifyWebhookSignature(PAYLOAD, "", SECRET_A)).toBe(false); - expect(verifyWebhookSignature(PAYLOAD, computeWebhookSignature(PAYLOAD, SECRET_A), "")).toBe(false); + expect(verifyWebhookSignature(PAYLOAD, computeWebhookSignature(PAYLOAD, SECRET_A), "")).toBe( + false + ); }); it("should never throw an exception", () => { diff --git a/tests/validate-invoice-for-publish.test.ts b/tests/validate-invoice-for-publish.test.ts index 0a5f239..157d11d 100644 --- a/tests/validate-invoice-for-publish.test.ts +++ b/tests/validate-invoice-for-publish.test.ts @@ -97,7 +97,7 @@ describe("validateInvoiceForPublish", () => { const errors = validateInvoiceForPublish(invoice); expect(errors).toHaveLength(3); expect(errors.map((e) => e.code)).toEqual( - expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_IN_PAST", "MISSING_DOCUMENT"]), + expect.arrayContaining(["FACE_VALUE_TOO_LOW", "DUE_DATE_IN_PAST", "MISSING_DOCUMENT"]) ); }); @@ -131,4 +131,4 @@ describe("validateInvoiceForPublish", () => { const errorsAgain = validateInvoiceForPublish(fullyInvalidInvoice); expect(errorsAgain).toEqual(errors); }); -}); \ No newline at end of file +}); diff --git a/tests/verify-payment.idempotency.test.ts b/tests/verify-payment.idempotency.test.ts index 2468ce2..c9039d8 100644 --- a/tests/verify-payment.idempotency.test.ts +++ b/tests/verify-payment.idempotency.test.ts @@ -236,7 +236,7 @@ describe("VerifyPaymentService idempotency (transaction hash as dedup key)", () expect(context.transactions.get(investmentA.id)).toHaveLength(1); expect(context.transactions.get(investmentB.id)).toHaveLength(1); expect(context.transactions.get(investmentA.id)?.[0].id).not.toBe( - context.transactions.get(investmentB.id)?.[0].id, + context.transactions.get(investmentB.id)?.[0].id ); }); }); diff --git a/tests/verify-payment.service.test.ts b/tests/verify-payment.service.test.ts index cfb3eb9..dbed36c 100644 --- a/tests/verify-payment.service.test.ts +++ b/tests/verify-payment.service.test.ts @@ -128,7 +128,7 @@ describe("VerifyPaymentService", () => { ok: true, status: 200, body: { successful: true }, - }), + }) ) .mockResolvedValueOnce( createMockResponse({ @@ -140,15 +140,14 @@ describe("VerifyPaymentService", () => { { type: "payment", asset_code: "USDC", - asset_issuer: - "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + asset_issuer: "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", amount: "100.0000", to: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", }, ], }, }, - }), + }) ); const result = await context.service.verifyPayment({ @@ -158,9 +157,7 @@ describe("VerifyPaymentService", () => { expect(result.outcome).toBe("verified"); expect(result.status).toBe(InvestmentStatus.CONFIRMED); - expect(context.investmentStore.get(context.investment.id)?.transactionHash).toBe( - stellarTxHash, - ); + expect(context.investmentStore.get(context.investment.id)?.transactionHash).toBe(stellarTxHash); const savedTransactions = context.transactions.get(context.investment.id) ?? []; expect(savedTransactions).toHaveLength(1); @@ -169,7 +166,6 @@ describe("VerifyPaymentService", () => { expect(savedTransactions[0].invoiceId).toBe(context.investment.invoiceId); - const secondResult = await context.service.verifyPayment({ investmentId: context.investment.id, stellarTxHash, @@ -189,14 +185,14 @@ describe("VerifyPaymentService", () => { ok: false, status: 503, body: {}, - }), + }) ) .mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, body: { successful: true }, - }), + }) ) .mockResolvedValueOnce( createMockResponse({ @@ -208,15 +204,14 @@ describe("VerifyPaymentService", () => { { type: "payment", asset_code: "USDC", - asset_issuer: - "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + asset_issuer: "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", amount: "100.0000", to: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", }, ], }, }, - }), + }) ); const result = await context.service.verifyPayment({ @@ -238,14 +233,14 @@ describe("VerifyPaymentService", () => { ok: false, status: 404, body: {}, - }), + }) ); await expect( context.service.verifyPayment({ investmentId: context.investment.id, stellarTxHash: "missing-hash", - }), + }) ).rejects.toMatchObject({ code: "transaction_not_found", statusCode: 404, @@ -261,7 +256,7 @@ describe("VerifyPaymentService", () => { ok: true, status: 200, body: { successful: true }, - }), + }) ) .mockResolvedValueOnce( createMockResponse({ @@ -279,14 +274,14 @@ describe("VerifyPaymentService", () => { ], }, }, - }), + }) ); await expect( context.service.verifyPayment({ investmentId: context.investment.id, stellarTxHash: "bad-payment", - }), + }) ).rejects.toMatchObject({ code: "invalid_payment", statusCode: 422, diff --git a/tests/webhook-dispatcher.service.test.ts b/tests/webhook-dispatcher.service.test.ts index 3fdccc9..e7aab7a 100644 --- a/tests/webhook-dispatcher.service.test.ts +++ b/tests/webhook-dispatcher.service.test.ts @@ -1,27 +1,55 @@ -import { WebhookDispatcherService, generateWebhookSignature } from "../src/services/webhook-dispatcher.service"; +import { + WebhookDispatcherService, + generateWebhookSignature, +} from "../src/services/webhook-dispatcher.service"; import { WebhookSubscription } from "../src/models/WebhookSubscription.model"; import { WebhookDeliveryLog } from "../src/models/WebhookDeliveryLog.model"; describe("WebhookDispatcherService", () => { - const subscription = { id: "sub-1", url: "https://example.test/hook", secret: "secret", eventTypes: ["invoice.published"], active: true } as WebhookSubscription; + const subscription = { + id: "sub-1", + url: "https://example.test/hook", + secret: "secret", + eventTypes: ["invoice.published"], + active: true, + } as WebhookSubscription; const log = { save: jest.fn().mockResolvedValue(undefined) }; - const dataSource = { getRepository: jest.fn((entity) => entity === WebhookSubscription ? { find: jest.fn().mockResolvedValue([subscription]) } : log) }; + const dataSource = { + getRepository: jest.fn((entity) => + entity === WebhookSubscription ? { find: jest.fn().mockResolvedValue([subscription]) } : log + ), + }; afterEach(() => jest.restoreAllMocks()); - it("generates a deterministic HMAC signature", () => expect(generateWebhookSignature("payload", "secret")).toBe("b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4")); + it("generates a deterministic HMAC signature", () => + expect(generateWebhookSignature("payload", "secret")).toBe( + "b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4" + )); it("posts signed event payloads and records delivery", async () => { global.fetch = jest.fn().mockResolvedValue({ status: 202 }) as any; - const result = await new WebhookDispatcherService(dataSource as any).dispatchWebhookEvent("invoice.published", { id: "invoice-1" }); + const result = await new WebhookDispatcherService(dataSource as any).dispatchWebhookEvent( + "invoice.published", + { id: "invoice-1" } + ); expect(result[0]).toMatchObject({ delivered: true, attempts: 1, responseStatus: 202 }); - expect(global.fetch).toHaveBeenCalledWith(subscription.url, expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "x-signature": expect.any(String) }) })); + expect(global.fetch).toHaveBeenCalledWith( + subscription.url, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "x-signature": expect.any(String) }), + }) + ); expect(log.save).toHaveBeenCalledWith(expect.objectContaining({ delivered: true })); }); it("retries failed responses three times", async () => { global.fetch = jest.fn().mockResolvedValue({ status: 500 }) as any; - const result = await new WebhookDispatcherService(dataSource as any).dispatchWebhookEvent("invoice.published", {}); + const result = await new WebhookDispatcherService(dataSource as any).dispatchWebhookEvent( + "invoice.published", + {} + ); expect(result[0]).toMatchObject({ delivered: false, attempts: 3, responseStatus: 500 }); expect(global.fetch).toHaveBeenCalledTimes(3); });