diff --git a/backend/.env.example b/backend/.env.example index f6414fcf..55668982 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,9 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 LOG_LEVEL= FRONTEND_URL=http://localhost:3000 +# Server Configuration +PORT=3001 + DATABASE_URL=postgres://postgres:postgres@db:5432/remitlend # Docker default (use redis://localhost:6379 if running Redis outside docker-compose) REDIS_URL=redis://redis:6379 @@ -44,6 +47,8 @@ SCORE_DELTA_LATE=5 # Indexer Configuration INDEXER_POLL_INTERVAL_MS=30000 INDEXER_BATCH_SIZE=100 +# Maximum ledger lag before /health/deep reports indexer as degraded +INDEXER_HEALTH_LAG_LIMIT=100 # Default checker (on-chain `check_defaults`) # How often to scan + submit default checks while the API process is running diff --git a/backend/.eslintrc.cjs b/backend/.eslintrc.cjs index 39e0ea1d..6de7acec 100644 --- a/backend/.eslintrc.cjs +++ b/backend/.eslintrc.cjs @@ -5,43 +5,38 @@ module.exports = { es2021: true, jest: true, }, - parser: "@typescript-eslint/parser", + parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2020, - sourceType: "module", + sourceType: 'module', tsconfigRootDir: __dirname, - project: "./tsconfig.json", + project: './tsconfig.json', }, - plugins: ["@typescript-eslint", "prettier"], + plugins: ['@typescript-eslint', 'prettier'], extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended", + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', ], rules: { - "prettier/prettier": "error", - "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], - "@typescript-eslint/explicit-module-boundary-types": "off", - "no-console": ["warn", { allow: ["warn", "error"] }], - "@typescript-eslint/no-explicit-any": "warn", - "no-constant-condition": "off", + 'prettier/prettier': 'error', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'no-console': ['warn', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-explicit-any': 'warn', + 'no-constant-condition': 'off', }, overrides: [ { - files: [ - "**/*.test.ts", - "**/*.spec.ts", - "src/tests/**/*.ts", - "src/**/__tests__/**/*.ts", - ], + files: ['**/*.test.ts', '**/*.spec.ts', 'src/tests/**/*.ts', 'src/**/__tests__/**/*.ts'], rules: { - "no-useless-catch": "off", + 'no-useless-catch': 'off', }, }, { - files: ["src/utils/demo*.ts"], + files: ['src/utils/demo*.ts'], rules: { - "no-console": "off", + 'no-console': 'off', }, }, ], diff --git a/backend/README.md b/backend/README.md index c3f4e619..ce07dc32 100644 --- a/backend/README.md +++ b/backend/README.md @@ -295,7 +295,7 @@ backend/ Centralized error handling middleware that catches and formats errors. ```typescript -import { errorHandler } from "./middleware/errorHandler"; +import { errorHandler } from './middleware/errorHandler'; app.use(errorHandler); ``` @@ -304,10 +304,10 @@ app.use(errorHandler); Request validation using Zod schemas. ```typescript -import { validate } from "./middleware/validation"; -import { mySchema } from "./schemas/mySchemas"; +import { validate } from './middleware/validation'; +import { mySchema } from './schemas/mySchemas'; -router.post("/endpoint", validate(mySchema), controller); +router.post('/endpoint', validate(mySchema), controller); ``` ### Rate Limiter @@ -315,8 +315,8 @@ router.post("/endpoint", validate(mySchema), controller); Protects endpoints from abuse with configurable rate limits. ```typescript -import { rateLimiter } from "./middleware/rateLimiter"; -app.use("/api/", rateLimiter); +import { rateLimiter } from './middleware/rateLimiter'; +app.use('/api/', rateLimiter); ``` ### Async Handler @@ -324,10 +324,10 @@ app.use("/api/", rateLimiter); Wraps async route handlers to catch errors automatically. ```typescript -import { asyncHandler } from "./middleware/asyncHandler"; +import { asyncHandler } from './middleware/asyncHandler'; router.get( - "/endpoint", + '/endpoint', asyncHandler(async (req, res) => { // Async code here }), @@ -355,14 +355,14 @@ npm test -- --watch ### Test Structure ```typescript -import request from "supertest"; -import app from "../app"; +import request from 'supertest'; +import app from '../app'; -describe("GET /api/health", () => { - it("should return 200 OK", async () => { - const response = await request(app).get("/api/health").expect(200); +describe('GET /api/health', () => { + it('should return 200 OK', async () => { + const response = await request(app).get('/api/health').expect(200); - expect(response.body).toHaveProperty("status", "ok"); + expect(response.body).toHaveProperty('status', 'ok'); }); }); ``` @@ -381,9 +381,9 @@ Aim for >80% code coverage on new code. Current coverage: ### Custom Error Class ```typescript -import { AppError } from "./errors/AppError"; +import { AppError } from './errors/AppError'; -throw new AppError("User not found", 404); +throw new AppError('User not found', 404); ``` ### Error Response Format @@ -404,11 +404,11 @@ Validation schemas are defined using Zod in the `schemas/` directory. ### Example Schema ```typescript -import { z } from "zod"; +import { z } from 'zod'; export const getUserScoreSchema = z.object({ params: z.object({ - userId: z.string().min(1, "User ID is required"), + userId: z.string().min(1, 'User ID is required'), }), }); diff --git a/backend/jest.config.ts b/backend/jest.config.ts index 712b58db..30f84e0c 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -1,32 +1,32 @@ -import type { Config } from "jest"; +import type { Config } from 'jest'; const config: Config = { - preset: "ts-jest", - testEnvironment: "node", - testMatch: ["**/*.test.ts", "**/*.spec.ts"], - setupFilesAfterEnv: ["/src/tests/jest.setup.js"], - moduleFileExtensions: ["ts", "js", "json", "node"], + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts', '**/*.spec.ts'], + setupFilesAfterEnv: ['/src/tests/jest.setup.js'], + moduleFileExtensions: ['ts', 'js', 'json', 'node'], transform: { - "^.+\\.(ts|tsx)$": [ - "ts-jest", + '^.+\\.(ts|tsx)$': [ + 'ts-jest', { useESM: true, tsconfig: { - module: "esnext", - moduleResolution: "bundler", + module: 'esnext', + moduleResolution: 'bundler', }, }, ], }, - extensionsToTreatAsEsm: [".ts"], + extensionsToTreatAsEsm: ['.ts'], globals: { - "ts-jest": { + 'ts-jest': { useESM: true, }, }, moduleNameMapper: { // Correct pattern - strips .js so Jest finds the .ts source file - "^(./|../)(.*)\\.js$": "$1$2", + '^(./|../)(.*)\\.js$': '$1$2', }, }; diff --git a/backend/migrations/1771691269865_initial-schema.js b/backend/migrations/1771691269865_initial-schema.js index 859bbb92..3262919a 100644 --- a/backend/migrations/1771691269865_initial-schema.js +++ b/backend/migrations/1771691269865_initial-schema.js @@ -9,31 +9,31 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("scores", { - id: "id", - user_id: { type: "varchar(255)", notNull: true, unique: true }, - current_score: { type: "integer", notNull: true, default: 500 }, + pgm.createTable('scores', { + id: 'id', + user_id: { type: 'varchar(255)', notNull: true, unique: true }, + current_score: { type: 'integer', notNull: true, default: 500 }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createTable("remittance_history", { - id: "id", - user_id: { type: "varchar(255)", notNull: true }, - amount: { type: "numeric", notNull: true }, - month: { type: "varchar(50)", notNull: true }, - status: { type: "varchar(50)", notNull: true }, + pgm.createTable('remittance_history', { + id: 'id', + user_id: { type: 'varchar(255)', notNull: true }, + amount: { type: 'numeric', notNull: true }, + month: { type: 'varchar(50)', notNull: true }, + status: { type: 'varchar(50)', notNull: true }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("remittance_history", "user_id"); + pgm.createIndex('remittance_history', 'user_id'); }; /** @@ -42,6 +42,6 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("remittance_history"); - pgm.dropTable("scores"); + pgm.dropTable('remittance_history'); + pgm.dropTable('scores'); }; diff --git a/backend/migrations/1771691269866_loan-events-schema.js b/backend/migrations/1771691269866_loan-events-schema.js index b524c057..c288462f 100644 --- a/backend/migrations/1771691269866_loan-events-schema.js +++ b/backend/migrations/1771691269866_loan-events-schema.js @@ -9,41 +9,41 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("loan_events", { - id: "id", - event_id: { type: "varchar(255)", notNull: true, unique: true }, - event_type: { type: "varchar(50)", notNull: true }, - loan_id: { type: "integer" }, - borrower: { type: "varchar(255)", notNull: true }, - amount: { type: "numeric" }, - ledger: { type: "integer", notNull: true }, - ledger_closed_at: { type: "timestamp", notNull: true }, - tx_hash: { type: "varchar(255)", notNull: true }, - contract_id: { type: "varchar(255)", notNull: true }, - topics: { type: "jsonb" }, - value: { type: "text" }, + pgm.createTable('loan_events', { + id: 'id', + event_id: { type: 'varchar(255)', notNull: true, unique: true }, + event_type: { type: 'varchar(50)', notNull: true }, + loan_id: { type: 'integer' }, + borrower: { type: 'varchar(255)', notNull: true }, + amount: { type: 'numeric' }, + ledger: { type: 'integer', notNull: true }, + ledger_closed_at: { type: 'timestamp', notNull: true }, + tx_hash: { type: 'varchar(255)', notNull: true }, + contract_id: { type: 'varchar(255)', notNull: true }, + topics: { type: 'jsonb' }, + value: { type: 'text' }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("loan_events", "event_type"); - pgm.createIndex("loan_events", "borrower"); - pgm.createIndex("loan_events", "loan_id"); - pgm.createIndex("loan_events", "ledger"); - pgm.createIndex("loan_events", "tx_hash"); + pgm.createIndex('loan_events', 'event_type'); + pgm.createIndex('loan_events', 'borrower'); + pgm.createIndex('loan_events', 'loan_id'); + pgm.createIndex('loan_events', 'ledger'); + pgm.createIndex('loan_events', 'tx_hash'); // Table to track indexer state - pgm.createTable("indexer_state", { - id: "id", - last_indexed_ledger: { type: "integer", notNull: true, default: 0 }, - last_indexed_cursor: { type: "varchar(255)" }, + pgm.createTable('indexer_state', { + id: 'id', + last_indexed_ledger: { type: 'integer', notNull: true, default: 0 }, + last_indexed_cursor: { type: 'varchar(255)' }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); @@ -60,6 +60,6 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("indexer_state"); - pgm.dropTable("loan_events"); + pgm.dropTable('indexer_state'); + pgm.dropTable('loan_events'); }; diff --git a/backend/migrations/1772000000000_webhook-subscriptions.js b/backend/migrations/1772000000000_webhook-subscriptions.js index 8e1879db..2c71b7b9 100644 --- a/backend/migrations/1772000000000_webhook-subscriptions.js +++ b/backend/migrations/1772000000000_webhook-subscriptions.js @@ -8,56 +8,56 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("webhook_subscriptions", { - id: "id", - callback_url: { type: "text", notNull: true }, - event_types: { type: "jsonb", notNull: true, default: "[]::jsonb" }, - secret: { type: "varchar(255)" }, - is_active: { type: "boolean", notNull: true, default: true }, + pgm.createTable('webhook_subscriptions', { + id: 'id', + callback_url: { type: 'text', notNull: true }, + event_types: { type: 'jsonb', notNull: true, default: '[]::jsonb' }, + secret: { type: 'varchar(255)' }, + is_active: { type: 'boolean', notNull: true, default: true }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createTable("webhook_deliveries", { - id: "id", + pgm.createTable('webhook_deliveries', { + id: 'id', subscription_id: { - type: "integer", + type: 'integer', notNull: true, - references: "webhook_subscriptions", - onDelete: "CASCADE", + references: 'webhook_subscriptions', + onDelete: 'CASCADE', }, - event_id: { type: "varchar(255)", notNull: true }, - event_type: { type: "varchar(50)", notNull: true }, - payload: { type: "jsonb", notNull: true }, - attempt_count: { type: "integer", notNull: true, default: 0 }, - last_status_code: { type: "integer" }, - last_error: { type: "text" }, - delivered_at: { type: "timestamp" }, - next_retry_at: { type: "timestamp" }, + event_id: { type: 'varchar(255)', notNull: true }, + event_type: { type: 'varchar(50)', notNull: true }, + payload: { type: 'jsonb', notNull: true }, + attempt_count: { type: 'integer', notNull: true, default: 0 }, + last_status_code: { type: 'integer' }, + last_error: { type: 'text' }, + delivered_at: { type: 'timestamp' }, + next_retry_at: { type: 'timestamp' }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("webhook_subscriptions", "is_active"); - pgm.createIndex("webhook_deliveries", "event_id"); - pgm.createIndex("webhook_deliveries", "subscription_id"); - pgm.createIndex("webhook_deliveries", ["next_retry_at", "delivered_at"]); + pgm.createIndex('webhook_subscriptions', 'is_active'); + pgm.createIndex('webhook_deliveries', 'event_id'); + pgm.createIndex('webhook_deliveries', 'subscription_id'); + pgm.createIndex('webhook_deliveries', ['next_retry_at', 'delivered_at']); }; /** @@ -65,6 +65,6 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("webhook_deliveries"); - pgm.dropTable("webhook_subscriptions"); + pgm.dropTable('webhook_deliveries'); + pgm.dropTable('webhook_subscriptions'); }; diff --git a/backend/migrations/1773000000001_user-profiles.js b/backend/migrations/1773000000001_user-profiles.js index 5dd5caa7..5224a2f0 100644 --- a/backend/migrations/1773000000001_user-profiles.js +++ b/backend/migrations/1773000000001_user-profiles.js @@ -9,28 +9,28 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("user_profiles", { - id: "id", - public_key: { type: "varchar(255)", notNull: true, unique: true }, - display_name: { type: "varchar(255)" }, - email: { type: "varchar(255)" }, - phone: { type: "varchar(50)" }, - email_enabled: { type: "boolean", notNull: true, default: true }, - sms_enabled: { type: "boolean", notNull: true, default: true }, + pgm.createTable('user_profiles', { + id: 'id', + public_key: { type: 'varchar(255)', notNull: true, unique: true }, + display_name: { type: 'varchar(255)' }, + email: { type: 'varchar(255)' }, + phone: { type: 'varchar(50)' }, + email_enabled: { type: 'boolean', notNull: true, default: true }, + sms_enabled: { type: 'boolean', notNull: true, default: true }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, - metadata: { type: "jsonb" }, + metadata: { type: 'jsonb' }, }); - pgm.createIndex("user_profiles", "public_key"); + pgm.createIndex('user_profiles', 'public_key'); }; /** @@ -39,5 +39,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("user_profiles"); + pgm.dropTable('user_profiles'); }; diff --git a/backend/migrations/1773000000002_loan-history.js b/backend/migrations/1773000000002_loan-history.js index e4953f39..5837f3a7 100644 --- a/backend/migrations/1773000000002_loan-history.js +++ b/backend/migrations/1773000000002_loan-history.js @@ -9,39 +9,39 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("loan_history", { - id: "id", - loan_id: { type: "integer", notNull: true }, - borrower_public_key: { type: "varchar(255)", notNull: true }, - lender_public_key: { type: "varchar(255)" }, - principal_amount: { type: "numeric", notNull: true }, - interest_rate_bps: { type: "integer", notNull: true }, - principal_paid: { type: "numeric", default: 0 }, - interest_paid: { type: "numeric", default: 0 }, - accrued_interest: { type: "numeric", default: 0 }, - status: { type: "varchar(50)", notNull: true }, - due_date: { type: "timestamp" }, - requested_at: { type: "timestamp" }, - approved_at: { type: "timestamp" }, - repaid_at: { type: "timestamp" }, - defaulted_at: { type: "timestamp" }, + pgm.createTable('loan_history', { + id: 'id', + loan_id: { type: 'integer', notNull: true }, + borrower_public_key: { type: 'varchar(255)', notNull: true }, + lender_public_key: { type: 'varchar(255)' }, + principal_amount: { type: 'numeric', notNull: true }, + interest_rate_bps: { type: 'integer', notNull: true }, + principal_paid: { type: 'numeric', default: 0 }, + interest_paid: { type: 'numeric', default: 0 }, + accrued_interest: { type: 'numeric', default: 0 }, + status: { type: 'varchar(50)', notNull: true }, + due_date: { type: 'timestamp' }, + requested_at: { type: 'timestamp' }, + approved_at: { type: 'timestamp' }, + repaid_at: { type: 'timestamp' }, + defaulted_at: { type: 'timestamp' }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, - metadata: { type: "jsonb" }, + metadata: { type: 'jsonb' }, }); - pgm.createIndex("loan_history", "loan_id"); - pgm.createIndex("loan_history", "borrower_public_key"); - pgm.createIndex("loan_history", "lender_public_key"); - pgm.createIndex("loan_history", "status"); + pgm.createIndex('loan_history', 'loan_id'); + pgm.createIndex('loan_history', 'borrower_public_key'); + pgm.createIndex('loan_history', 'lender_public_key'); + pgm.createIndex('loan_history', 'status'); }; /** @@ -50,5 +50,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("loan_history"); + pgm.dropTable('loan_history'); }; diff --git a/backend/migrations/1773000000003_indexed-events.js b/backend/migrations/1773000000003_indexed-events.js index 8b1b954b..b7c2a435 100644 --- a/backend/migrations/1773000000003_indexed-events.js +++ b/backend/migrations/1773000000003_indexed-events.js @@ -9,34 +9,34 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("indexed_events", { - id: "id", - event_id: { type: "varchar(255)", notNull: true, unique: true }, - event_type: { type: "varchar(50)", notNull: true }, - contract_id: { type: "varchar(255)", notNull: true }, - tx_hash: { type: "varchar(255)", notNull: true }, - ledger: { type: "integer", notNull: true }, - ledger_closed_at: { type: "timestamp", notNull: true }, - topics: { type: "jsonb" }, - value: { type: "text" }, - processed: { type: "boolean", notNull: true, default: false }, + pgm.createTable('indexed_events', { + id: 'id', + event_id: { type: 'varchar(255)', notNull: true, unique: true }, + event_type: { type: 'varchar(50)', notNull: true }, + contract_id: { type: 'varchar(255)', notNull: true }, + tx_hash: { type: 'varchar(255)', notNull: true }, + ledger: { type: 'integer', notNull: true }, + ledger_closed_at: { type: 'timestamp', notNull: true }, + topics: { type: 'jsonb' }, + value: { type: 'text' }, + processed: { type: 'boolean', notNull: true, default: false }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("indexed_events", "event_type"); - pgm.createIndex("indexed_events", "contract_id"); - pgm.createIndex("indexed_events", "ledger"); - pgm.createIndex("indexed_events", "tx_hash"); - pgm.createIndex("indexed_events", "processed"); + pgm.createIndex('indexed_events', 'event_type'); + pgm.createIndex('indexed_events', 'contract_id'); + pgm.createIndex('indexed_events', 'ledger'); + pgm.createIndex('indexed_events', 'tx_hash'); + pgm.createIndex('indexed_events', 'processed'); }; /** @@ -45,5 +45,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("indexed_events"); + pgm.dropTable('indexed_events'); }; diff --git a/backend/migrations/1775000000005_notifications.js b/backend/migrations/1775000000005_notifications.js index f4f419f5..da5e54ee 100644 --- a/backend/migrations/1775000000005_notifications.js +++ b/backend/migrations/1775000000005_notifications.js @@ -9,30 +9,30 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("notifications", { - id: "id", - user_id: { type: "varchar(255)", notNull: true }, + pgm.createTable('notifications', { + id: 'id', + user_id: { type: 'varchar(255)', notNull: true }, type: { - type: "varchar(50)", + type: 'varchar(50)', notNull: true, comment: - "loan_approved | repayment_due | repayment_confirmed | loan_defaulted | score_changed", + 'loan_approved | repayment_due | repayment_confirmed | loan_defaulted | score_changed', }, - title: { type: "varchar(255)", notNull: true }, - message: { type: "text", notNull: true }, - loan_id: { type: "integer", notNull: false }, - read: { type: "boolean", notNull: true, default: false }, + title: { type: 'varchar(255)', notNull: true }, + message: { type: 'text', notNull: true }, + loan_id: { type: 'integer', notNull: false }, + read: { type: 'boolean', notNull: true, default: false }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("notifications", "user_id"); - pgm.createIndex("notifications", "read"); - pgm.createIndex("notifications", ["user_id", "read"]); - pgm.createIndex("notifications", "created_at"); + pgm.createIndex('notifications', 'user_id'); + pgm.createIndex('notifications', 'read'); + pgm.createIndex('notifications', ['user_id', 'read']); + pgm.createIndex('notifications', 'created_at'); }; /** @@ -41,5 +41,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("notifications"); + pgm.dropTable('notifications'); }; diff --git a/backend/migrations/1776000000006_add-interest-rate-to-loan-events.js b/backend/migrations/1776000000006_add-interest-rate-to-loan-events.js index 11554666..dfb63d5f 100644 --- a/backend/migrations/1776000000006_add-interest-rate-to-loan-events.js +++ b/backend/migrations/1776000000006_add-interest-rate-to-loan-events.js @@ -1,7 +1,7 @@ exports.up = (pgm) => { - pgm.addColumns("loan_events", { - interest_rate_bps: { type: "integer", default: null }, - term_ledgers: { type: "integer", default: null }, + pgm.addColumns('loan_events', { + interest_rate_bps: { type: 'integer', default: null }, + term_ledgers: { type: 'integer', default: null }, }); // Also add a score penalty for defaulted loans in the metadata if needed, @@ -9,5 +9,5 @@ exports.up = (pgm) => { }; exports.down = (pgm) => { - pgm.dropColumns("loan_events", ["interest_rate_bps", "term_ledgers"]); + pgm.dropColumns('loan_events', ['interest_rate_bps', 'term_ledgers']); }; diff --git a/backend/migrations/1777000000007_loan-events-composite-indexes.js b/backend/migrations/1777000000007_loan-events-composite-indexes.js index abecf0d0..a16df9e9 100644 --- a/backend/migrations/1777000000007_loan-events-composite-indexes.js +++ b/backend/migrations/1777000000007_loan-events-composite-indexes.js @@ -41,34 +41,34 @@ export const shorthands = undefined; */ export const up = (pgm) => { // (borrower, event_type) — borrower loan list + pool stats with borrower filter - pgm.createIndex("loan_events", ["borrower", "event_type"], { - name: "idx_loan_events_borrower_event_type", + pgm.createIndex('loan_events', ['borrower', 'event_type'], { + name: 'idx_loan_events_borrower_event_type', ifNotExists: true, }); // (loan_id, event_type) — loan detail fetch + defaultChecker repayment sub-query - pgm.createIndex("loan_events", ["loan_id", "event_type"], { - name: "idx_loan_events_loan_id_event_type", + pgm.createIndex('loan_events', ['loan_id', 'event_type'], { + name: 'idx_loan_events_loan_id_event_type', ifNotExists: true, }); // (event_type, loan_id) — defaultChecker approved-loans CTE - pgm.createIndex("loan_events", ["event_type", "loan_id"], { - name: "idx_loan_events_event_type_loan_id", + pgm.createIndex('loan_events', ['event_type', 'loan_id'], { + name: 'idx_loan_events_event_type_loan_id', ifNotExists: true, }); // (ledger) — already exists from the initial schema migration; declared // IF NOT EXISTS so this migration stays idempotent if re-run. - pgm.createIndex("loan_events", "ledger", { - name: "idx_loan_events_ledger", + pgm.createIndex('loan_events', 'ledger', { + name: 'idx_loan_events_ledger', ifNotExists: true, }); // partial index: (borrower) WHERE event_type IN ('Deposit', 'Withdraw') // Covers the pool controller query for per-borrower deposit/withdrawal totals. - pgm.createIndex("loan_events", "borrower", { - name: "idx_loan_events_pool_deposits_withdraws", + pgm.createIndex('loan_events', 'borrower', { + name: 'idx_loan_events_pool_deposits_withdraws', ifNotExists: true, where: "event_type IN ('Deposit', 'Withdraw')", }); @@ -79,8 +79,8 @@ export const up = (pgm) => { * @returns {void} */ export const down = (pgm) => { - pgm.dropIndex("loan_events", "borrower", { - name: "idx_loan_events_pool_deposits_withdraws", + pgm.dropIndex('loan_events', 'borrower', { + name: 'idx_loan_events_pool_deposits_withdraws', ifExists: true, }); @@ -88,18 +88,18 @@ export const down = (pgm) => { // dropping it here so rolling back this migration does not break the // earlier one. - pgm.dropIndex("loan_events", ["event_type", "loan_id"], { - name: "idx_loan_events_event_type_loan_id", + pgm.dropIndex('loan_events', ['event_type', 'loan_id'], { + name: 'idx_loan_events_event_type_loan_id', ifExists: true, }); - pgm.dropIndex("loan_events", ["loan_id", "event_type"], { - name: "idx_loan_events_loan_id_event_type", + pgm.dropIndex('loan_events', ['loan_id', 'event_type'], { + name: 'idx_loan_events_loan_id_event_type', ifExists: true, }); - pgm.dropIndex("loan_events", ["borrower", "event_type"], { - name: "idx_loan_events_borrower_event_type", + pgm.dropIndex('loan_events', ['borrower', 'event_type'], { + name: 'idx_loan_events_borrower_event_type', ifExists: true, }); }; diff --git a/backend/migrations/1777000000007_unique-loan-status-events.js b/backend/migrations/1777000000007_unique-loan-status-events.js index cc2cc4d1..045f3ca3 100644 --- a/backend/migrations/1777000000007_unique-loan-status-events.js +++ b/backend/migrations/1777000000007_unique-loan-status-events.js @@ -44,5 +44,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.sql("DROP INDEX IF EXISTS loan_events_unique_status_event_per_loan"); + pgm.sql('DROP INDEX IF EXISTS loan_events_unique_status_event_per_loan'); }; diff --git a/backend/migrations/1778000000008_quarantine-events.js b/backend/migrations/1778000000008_quarantine-events.js index d18a7509..0e5faf94 100644 --- a/backend/migrations/1778000000008_quarantine-events.js +++ b/backend/migrations/1778000000008_quarantine-events.js @@ -26,23 +26,23 @@ export const shorthands = undefined; * @returns {void} */ export const up = (pgm) => { - pgm.createTable("quarantine_events", { - id: { type: "serial", primaryKey: true }, - event_id: { type: "varchar(255)", notNull: true, unique: true }, - ledger: { type: "integer", notNull: true }, - tx_hash: { type: "varchar(255)", notNull: true }, - contract_id: { type: "varchar(255)", notNull: true }, - raw_xdr: { type: "jsonb", notNull: true }, - error_message: { type: "text", notNull: true }, + pgm.createTable('quarantine_events', { + id: { type: 'serial', primaryKey: true }, + event_id: { type: 'varchar(255)', notNull: true, unique: true }, + ledger: { type: 'integer', notNull: true }, + tx_hash: { type: 'varchar(255)', notNull: true }, + contract_id: { type: 'varchar(255)', notNull: true }, + raw_xdr: { type: 'jsonb', notNull: true }, + error_message: { type: 'text', notNull: true }, quarantined_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("quarantine_events", "ledger"); - pgm.createIndex("quarantine_events", "quarantined_at"); + pgm.createIndex('quarantine_events', 'ledger'); + pgm.createIndex('quarantine_events', 'quarantined_at'); }; /** @@ -50,5 +50,5 @@ export const up = (pgm) => { * @returns {void} */ export const down = (pgm) => { - pgm.dropTable("quarantine_events"); + pgm.dropTable('quarantine_events'); }; diff --git a/backend/migrations/1778000000008_transaction-submissions.js b/backend/migrations/1778000000008_transaction-submissions.js index 34a4dacc..7731bc1a 100644 --- a/backend/migrations/1778000000008_transaction-submissions.js +++ b/backend/migrations/1778000000008_transaction-submissions.js @@ -2,61 +2,61 @@ * @param { import("node-pg-migrate").MigrationBuilder } @param pgm {import("node-pg-migrate").MigrationBuilder} */ exports.up = (pgm) => { - pgm.createTable("transaction_submissions", { + pgm.createTable('transaction_submissions', { id: { - type: "serial", + type: 'serial', primaryKey: true, }, tx_hash: { - type: "varchar(64)", + type: 'varchar(64)', notNull: true, unique: true, }, status: { - type: "varchar(50)", + type: 'varchar(50)', notNull: true, }, submitted_at: { - type: "timestamp with time zone", + type: 'timestamp with time zone', notNull: true, - default: pgm.func("NOW()"), + default: pgm.func('NOW()'), }, submitted_by: { - type: "varchar(56)", + type: 'varchar(56)', null: true, }, transaction_type: { - type: "varchar(20)", + type: 'varchar(20)', notNull: true, - default: "loan", + default: 'loan', }, result_xdr: { - type: "text", + type: 'text', null: true, }, created_at: { - type: "timestamp with time zone", + type: 'timestamp with time zone', notNull: true, - default: pgm.func("NOW()"), + default: pgm.func('NOW()'), }, updated_at: { - type: "timestamp with time zone", + type: 'timestamp with time zone', notNull: true, - default: pgm.func("NOW()"), + default: pgm.func('NOW()'), }, }); // Indexes for performance - pgm.createIndex("transaction_submissions", ["submitted_at"]); - pgm.createIndex("transaction_submissions", ["submitted_by"]); - pgm.createIndex("transaction_submissions", ["status"]); - pgm.createIndex("transaction_submissions", ["transaction_type"]); + pgm.createIndex('transaction_submissions', ['submitted_at']); + pgm.createIndex('transaction_submissions', ['submitted_by']); + pgm.createIndex('transaction_submissions', ['status']); + pgm.createIndex('transaction_submissions', ['transaction_type']); // Trigger to update updated_at timestamp - pgm.createTrigger("transaction_submissions", "update_updated_at", { - when: "BEFORE", - operation: "UPDATE", - function: "update_updated_at_column", + pgm.createTrigger('transaction_submissions', 'update_updated_at', { + when: 'BEFORE', + operation: 'UPDATE', + function: 'update_updated_at_column', }); }; @@ -64,5 +64,5 @@ exports.up = (pgm) => { * @param { import("node-pg-migrate").MigrationBuilder } @param pgm {import("node-pg-migrate").MigrationBuilder} */ exports.down = (pgm) => { - pgm.dropTable("transaction_submissions"); + pgm.dropTable('transaction_submissions'); }; diff --git a/backend/migrations/1779000000009_create-remittances-table.js b/backend/migrations/1779000000009_create-remittances-table.js index 04b33169..e5b1d00f 100644 --- a/backend/migrations/1779000000009_create-remittances-table.js +++ b/backend/migrations/1779000000009_create-remittances-table.js @@ -9,71 +9,71 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("remittances", { + pgm.createTable('remittances', { id: { - type: "uuid", + type: 'uuid', primaryKey: true, - default: pgm.func("gen_random_uuid()"), + default: pgm.func('gen_random_uuid()'), }, sender_id: { - type: "varchar(56)", + type: 'varchar(56)', notNull: true, }, recipient_address: { - type: "varchar(56)", + type: 'varchar(56)', notNull: true, }, amount: { - type: "numeric(20,7)", + type: 'numeric(20,7)', notNull: true, }, from_currency: { - type: "varchar(10)", + type: 'varchar(10)', notNull: true, }, to_currency: { - type: "varchar(10)", + type: 'varchar(10)', notNull: true, }, memo: { - type: "varchar(28)", + type: 'varchar(28)', allowNull: true, }, status: { - type: "varchar(20)", + type: 'varchar(20)', notNull: true, - default: "pending", + default: 'pending', check: "status IN ('pending', 'processing', 'completed', 'failed')", }, transaction_hash: { - type: "varchar(64)", + type: 'varchar(64)', allowNull: true, }, xdr: { - type: "text", + type: 'text', notNull: true, }, error_message: { - type: "text", + type: 'text', allowNull: true, }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, updated_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); // Indexes for common queries - pgm.createIndex("remittances", "sender_id"); - pgm.createIndex("remittances", ["sender_id", "status"]); - pgm.createIndex("remittances", "created_at"); - pgm.createIndex("remittances", "transaction_hash"); + pgm.createIndex('remittances', 'sender_id'); + pgm.createIndex('remittances', ['sender_id', 'status']); + pgm.createIndex('remittances', 'created_at'); + pgm.createIndex('remittances', 'transaction_hash'); }; /** @@ -82,5 +82,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("remittances"); + pgm.dropTable('remittances'); }; diff --git a/backend/migrations/1780000000010_audit-logs.js b/backend/migrations/1780000000010_audit-logs.js index 57250839..6bd94697 100644 --- a/backend/migrations/1780000000010_audit-logs.js +++ b/backend/migrations/1780000000010_audit-logs.js @@ -8,23 +8,23 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.createTable("audit_logs", { - id: "id", - actor: { type: "varchar(255)", notNull: true }, - action: { type: "varchar(255)", notNull: true }, - target: { type: "varchar(255)", notNull: false }, - payload: { type: "jsonb", notNull: false }, - ip_address: { type: "varchar(50)", notNull: false }, + pgm.createTable('audit_logs', { + id: 'id', + actor: { type: 'varchar(255)', notNull: true }, + action: { type: 'varchar(255)', notNull: true }, + target: { type: 'varchar(255)', notNull: false }, + payload: { type: 'jsonb', notNull: false }, + ip_address: { type: 'varchar(50)', notNull: false }, created_at: { - type: "timestamp", + type: 'timestamp', notNull: true, - default: pgm.func("current_timestamp"), + default: pgm.func('current_timestamp'), }, }); - pgm.createIndex("audit_logs", "actor"); - pgm.createIndex("audit_logs", "action"); - pgm.createIndex("audit_logs", "created_at"); + pgm.createIndex('audit_logs', 'actor'); + pgm.createIndex('audit_logs', 'action'); + pgm.createIndex('audit_logs', 'created_at'); }; /** @@ -32,5 +32,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropTable("audit_logs"); + pgm.dropTable('audit_logs'); }; diff --git a/backend/migrations/1781000000011_webhook-retry-logic.js b/backend/migrations/1781000000011_webhook-retry-logic.js index 775b3588..01f731a9 100644 --- a/backend/migrations/1781000000011_webhook-retry-logic.js +++ b/backend/migrations/1781000000011_webhook-retry-logic.js @@ -9,28 +9,28 @@ export const shorthands = undefined; */ export const up = (pgm) => { // Add payload column to webhook_deliveries table - pgm.addColumn("webhook_deliveries", { + pgm.addColumn('webhook_deliveries', { payload: { - type: "jsonb", + type: 'jsonb', notNull: false, }, }); // Add next_retry_at column to track when to retry - pgm.addColumn("webhook_deliveries", { + pgm.addColumn('webhook_deliveries', { next_retry_at: { - type: "timestamp", + type: 'timestamp', notNull: false, }, }); // Add index for efficient retry polling - pgm.createIndex("webhook_deliveries", ["next_retry_at"], { - where: "next_retry_at IS NOT NULL AND delivered_at IS NULL", + pgm.createIndex('webhook_deliveries', ['next_retry_at'], { + where: 'next_retry_at IS NOT NULL AND delivered_at IS NULL', }); // Add index for subscription + event tracking - pgm.createIndex("webhook_deliveries", ["subscription_id", "event_id"]); + pgm.createIndex('webhook_deliveries', ['subscription_id', 'event_id']); }; /** @@ -38,8 +38,8 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropIndex("webhook_deliveries", ["subscription_id", "event_id"]); - pgm.dropIndex("webhook_deliveries", ["next_retry_at"]); - pgm.dropColumn("webhook_deliveries", "next_retry_at"); - pgm.dropColumn("webhook_deliveries", "payload"); + pgm.dropIndex('webhook_deliveries', ['subscription_id', 'event_id']); + pgm.dropIndex('webhook_deliveries', ['next_retry_at']); + pgm.dropColumn('webhook_deliveries', 'next_retry_at'); + pgm.dropColumn('webhook_deliveries', 'payload'); }; diff --git a/backend/migrations/1782000000012_ensure-unique-loan-approved-events.js b/backend/migrations/1782000000012_ensure-unique-loan-approved-events.js index 11631f13..c9e05cf9 100644 --- a/backend/migrations/1782000000012_ensure-unique-loan-approved-events.js +++ b/backend/migrations/1782000000012_ensure-unique-loan-approved-events.js @@ -59,5 +59,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.sql("DROP INDEX IF EXISTS loan_events_unique_approved_event_per_loan"); + pgm.sql('DROP INDEX IF EXISTS loan_events_unique_approved_event_per_loan'); }; diff --git a/backend/migrations/1783000000013_notifications-add-status.js b/backend/migrations/1783000000013_notifications-add-status.js index ebc559a9..98ee41ad 100644 --- a/backend/migrations/1783000000013_notifications-add-status.js +++ b/backend/migrations/1783000000013_notifications-add-status.js @@ -10,32 +10,30 @@ export const shorthands = undefined; */ export const up = (pgm) => { // Add status column with check constraint - pgm.addColumn("notifications", { + pgm.addColumn('notifications', { status: { - type: "varchar(20)", + type: 'varchar(20)', notNull: true, - default: "unread", - comment: "unread | read | archived", + default: 'unread', + comment: 'unread | read | archived', }, }); pgm.addConstraint( - "notifications", - "notifications_status_check", + 'notifications', + 'notifications_status_check', "CHECK (status IN ('unread', 'read', 'archived'))", ); // Backfill status from existing read boolean - pgm.sql( - `UPDATE notifications SET status = CASE WHEN read = true THEN 'read' ELSE 'unread' END`, - ); + pgm.sql(`UPDATE notifications SET status = CASE WHEN read = true THEN 'read' ELSE 'unread' END`); // Index on status for filtering - pgm.createIndex("notifications", "status", { ifNotExists: true }); + pgm.createIndex('notifications', 'status', { ifNotExists: true }); // Composite index on (status, created_at) for efficient status-based cleanup queries - pgm.createIndex("notifications", ["status", "created_at"], { - name: "idx_notifications_status_created_at", + pgm.createIndex('notifications', ['status', 'created_at'], { + name: 'idx_notifications_status_created_at', ifNotExists: true, }); }; @@ -46,13 +44,13 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropIndex("notifications", ["status", "created_at"], { - name: "idx_notifications_status_created_at", + pgm.dropIndex('notifications', ['status', 'created_at'], { + name: 'idx_notifications_status_created_at', ifExists: true, }); - pgm.dropIndex("notifications", "status", { ifExists: true }); - pgm.dropConstraint("notifications", "notifications_status_check", { + pgm.dropIndex('notifications', 'status', { ifExists: true }); + pgm.dropConstraint('notifications', 'notifications_status_check', { ifExists: true, }); - pgm.dropColumn("notifications", "status"); + pgm.dropColumn('notifications', 'status'); }; diff --git a/backend/migrations/1785000000015_event-id-unique-constraints.js b/backend/migrations/1785000000015_event-id-unique-constraints.js index be14ab0a..0afd6ecc 100644 --- a/backend/migrations/1785000000015_event-id-unique-constraints.js +++ b/backend/migrations/1785000000015_event-id-unique-constraints.js @@ -5,16 +5,16 @@ export const shorthands = undefined; const eventIdTables = [ { - table: "loan_events", - indexName: "loan_events_event_id_unique_idx", + table: 'loan_events', + indexName: 'loan_events_event_id_unique_idx', }, { - table: "indexed_events", - indexName: "indexed_events_event_id_unique_idx", + table: 'indexed_events', + indexName: 'indexed_events_event_id_unique_idx', }, { - table: "quarantine_events", - indexName: "quarantine_events_event_id_unique_idx", + table: 'quarantine_events', + indexName: 'quarantine_events_event_id_unique_idx', }, ]; diff --git a/backend/migrations/1786000000016_ensure-loan-events-loan-id-index.js b/backend/migrations/1786000000016_ensure-loan-events-loan-id-index.js index 0997a8dc..9a80f3c8 100644 --- a/backend/migrations/1786000000016_ensure-loan-events-loan-id-index.js +++ b/backend/migrations/1786000000016_ensure-loan-events-loan-id-index.js @@ -26,5 +26,5 @@ export const up = (pgm) => { * @param pgm {import('node-pg-migrate').MigrationBuilder} */ export const down = (pgm) => { - pgm.sql("DROP INDEX IF EXISTS idx_loan_events_loan_id;"); + pgm.sql('DROP INDEX IF EXISTS idx_loan_events_loan_id;'); }; diff --git a/backend/migrations/1786000000016_webhook-max-attempts.js b/backend/migrations/1786000000016_webhook-max-attempts.js index db373888..0bc05691 100644 --- a/backend/migrations/1786000000016_webhook-max-attempts.js +++ b/backend/migrations/1786000000016_webhook-max-attempts.js @@ -8,9 +8,9 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.addColumn("webhook_subscriptions", { + pgm.addColumn('webhook_subscriptions', { max_attempts: { - type: "integer", + type: 'integer', notNull: true, default: 5, }, @@ -22,5 +22,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropColumn("webhook_subscriptions", "max_attempts"); + pgm.dropColumn('webhook_subscriptions', 'max_attempts'); }; diff --git a/backend/migrations/1787000000017_user-notification-preferences.js b/backend/migrations/1787000000017_user-notification-preferences.js index cf58cf0b..9448477c 100644 --- a/backend/migrations/1787000000017_user-notification-preferences.js +++ b/backend/migrations/1787000000017_user-notification-preferences.js @@ -8,10 +8,10 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.addColumns("user_profiles", { - email_enabled: { type: "boolean", notNull: true, default: false }, - sms_enabled: { type: "boolean", notNull: true, default: false }, - phone: { type: "varchar(20)" }, + pgm.addColumns('user_profiles', { + email_enabled: { type: 'boolean', notNull: true, default: false }, + sms_enabled: { type: 'boolean', notNull: true, default: false }, + phone: { type: 'varchar(20)' }, }); }; @@ -20,5 +20,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropColumns("user_profiles", ["email_enabled", "sms_enabled", "phone"]); + pgm.dropColumns('user_profiles', ['email_enabled', 'sms_enabled', 'phone']); }; diff --git a/backend/migrations/1788000000018_unified-contract-events.js b/backend/migrations/1788000000018_unified-contract-events.js index 9044ff96..cd8ec491 100644 --- a/backend/migrations/1788000000018_unified-contract-events.js +++ b/backend/migrations/1788000000018_unified-contract-events.js @@ -4,39 +4,35 @@ */ export const up = (pgm) => { // 1. Rename the table - pgm.renameTable("loan_events", "contract_events"); + pgm.renameTable('loan_events', 'contract_events'); // 2. Rename the column (Postgres handles index column updates automatically) - pgm.renameColumn("contract_events", "borrower", "address"); + pgm.renameColumn('contract_events', 'borrower', 'address'); // 3. Make address nullable (for events like YieldDistributed that may not have a user address) - pgm.alterColumn("contract_events", "address", { notNull: false }); + pgm.alterColumn('contract_events', 'address', { notNull: false }); // 4. Rename indexes to match the new table and column names pgm.renameIndex( - "contract_events", - "idx_loan_events_borrower_event_type", - "idx_contract_events_address_event_type", + 'contract_events', + 'idx_loan_events_borrower_event_type', + 'idx_contract_events_address_event_type', ); pgm.renameIndex( - "contract_events", - "idx_loan_events_loan_id_event_type", - "idx_contract_events_loan_id_event_type", + 'contract_events', + 'idx_loan_events_loan_id_event_type', + 'idx_contract_events_loan_id_event_type', ); pgm.renameIndex( - "contract_events", - "idx_loan_events_event_type_loan_id", - "idx_contract_events_event_type_loan_id", + 'contract_events', + 'idx_loan_events_event_type_loan_id', + 'idx_contract_events_event_type_loan_id', ); + pgm.renameIndex('contract_events', 'idx_loan_events_ledger', 'idx_contract_events_ledger'); pgm.renameIndex( - "contract_events", - "idx_loan_events_ledger", - "idx_contract_events_ledger", - ); - pgm.renameIndex( - "contract_events", - "idx_loan_events_pool_deposits_withdraws", - "idx_contract_events_pool_deposits_withdraws", + 'contract_events', + 'idx_loan_events_pool_deposits_withdraws', + 'idx_contract_events_pool_deposits_withdraws', ); // Rename single-column indexes from initial schema (if they exist) @@ -74,38 +70,34 @@ export const up = (pgm) => { * @returns {void} */ export const down = (pgm) => { - pgm.sql("DROP VIEW IF EXISTS loan_events"); + pgm.sql('DROP VIEW IF EXISTS loan_events'); - pgm.renameColumn("contract_events", "address", "borrower"); - pgm.alterColumn("contract_events", "borrower", { notNull: true }); + pgm.renameColumn('contract_events', 'address', 'borrower'); + pgm.alterColumn('contract_events', 'borrower', { notNull: true }); - pgm.renameTable("contract_events", "loan_events"); + pgm.renameTable('contract_events', 'loan_events'); // Revert index names pgm.renameIndex( - "loan_events", - "idx_contract_events_address_event_type", - "idx_loan_events_borrower_event_type", - ); - pgm.renameIndex( - "loan_events", - "idx_contract_events_loan_id_event_type", - "idx_loan_events_loan_id_event_type", + 'loan_events', + 'idx_contract_events_address_event_type', + 'idx_loan_events_borrower_event_type', ); pgm.renameIndex( - "loan_events", - "idx_contract_events_event_type_loan_id", - "idx_loan_events_event_type_loan_id", + 'loan_events', + 'idx_contract_events_loan_id_event_type', + 'idx_loan_events_loan_id_event_type', ); pgm.renameIndex( - "loan_events", - "idx_contract_events_ledger", - "idx_loan_events_ledger", + 'loan_events', + 'idx_contract_events_event_type_loan_id', + 'idx_loan_events_event_type_loan_id', ); + pgm.renameIndex('loan_events', 'idx_contract_events_ledger', 'idx_loan_events_ledger'); pgm.renameIndex( - "loan_events", - "idx_contract_events_pool_deposits_withdraws", - "idx_loan_events_pool_deposits_withdraws", + 'loan_events', + 'idx_contract_events_pool_deposits_withdraws', + 'idx_loan_events_pool_deposits_withdraws', ); pgm.sql(` diff --git a/backend/migrations/1791000000000_remittance-filters-index.js b/backend/migrations/1791000000000_remittance-filters-index.js index b4014df8..0d35f3f0 100644 --- a/backend/migrations/1791000000000_remittance-filters-index.js +++ b/backend/migrations/1791000000000_remittance-filters-index.js @@ -9,13 +9,13 @@ export const shorthands = undefined; */ export const up = (pgm) => { // Add composite index for filtering by sender, status, and created_at - pgm.createIndex("remittances", ["sender_id", "status", "created_at"], { - name: "idx_remittances_sender_status_created", + pgm.createIndex('remittances', ['sender_id', 'status', 'created_at'], { + name: 'idx_remittances_sender_status_created', }); // Add index for date range queries - pgm.createIndex("remittances", ["sender_id", "created_at"], { - name: "idx_remittances_sender_created", + pgm.createIndex('remittances', ['sender_id', 'created_at'], { + name: 'idx_remittances_sender_created', }); }; @@ -24,10 +24,10 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropIndex("remittances", ["sender_id", "status", "created_at"], { - name: "idx_remittances_sender_status_created", + pgm.dropIndex('remittances', ['sender_id', 'status', 'created_at'], { + name: 'idx_remittances_sender_status_created', }); - pgm.dropIndex("remittances", ["sender_id", "created_at"], { - name: "idx_remittances_sender_created", + pgm.dropIndex('remittances', ['sender_id', 'created_at'], { + name: 'idx_remittances_sender_created', }); }; diff --git a/backend/migrations/1792000000000_notification-filters-index.js b/backend/migrations/1792000000000_notification-filters-index.js index 8def526f..f35b0fae 100644 --- a/backend/migrations/1792000000000_notification-filters-index.js +++ b/backend/migrations/1792000000000_notification-filters-index.js @@ -9,17 +9,13 @@ export const shorthands = undefined; */ export const up = (pgm) => { // Add composite index for filtering by user, type, status, and created_at - pgm.createIndex( - "notifications", - ["user_id", "type", "status", "created_at"], - { - name: "idx_notifications_user_type_status_created", - }, - ); + pgm.createIndex('notifications', ['user_id', 'type', 'status', 'created_at'], { + name: 'idx_notifications_user_type_status_created', + }); // Add index for date range queries - pgm.createIndex("notifications", ["user_id", "created_at"], { - name: "idx_notifications_user_created", + pgm.createIndex('notifications', ['user_id', 'created_at'], { + name: 'idx_notifications_user_created', }); }; @@ -28,10 +24,10 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropIndex("notifications", ["user_id", "type", "status", "created_at"], { - name: "idx_notifications_user_type_status_created", + pgm.dropIndex('notifications', ['user_id', 'type', 'status', 'created_at'], { + name: 'idx_notifications_user_type_status_created', }); - pgm.dropIndex("notifications", ["user_id", "created_at"], { - name: "idx_notifications_user_created", + pgm.dropIndex('notifications', ['user_id', 'created_at'], { + name: 'idx_notifications_user_created', }); }; diff --git a/backend/migrations/1793000000000_add-digest-frequency.js b/backend/migrations/1793000000000_add-digest-frequency.js index 0eee35be..904d9ae6 100644 --- a/backend/migrations/1793000000000_add-digest-frequency.js +++ b/backend/migrations/1793000000000_add-digest-frequency.js @@ -8,13 +8,13 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.addColumns("user_notification_preferences", { + pgm.addColumns('user_notification_preferences', { digest_frequency: { - type: "varchar(20)", + type: 'varchar(20)', notNull: true, - default: "off", + default: 'off', check: "digest_frequency IN ('off', 'daily', 'weekly')", - comment: "Digest mode for repayment reminders: off, daily, or weekly", + comment: 'Digest mode for repayment reminders: off, daily, or weekly', }, }); }; @@ -24,5 +24,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropColumns("user_notification_preferences", ["digest_frequency"]); + pgm.dropColumns('user_notification_preferences', ['digest_frequency']); }; diff --git a/backend/migrations/1794000000000_add-action-url-to-notifications.js b/backend/migrations/1794000000000_add-action-url-to-notifications.js index 26e94c70..7c8c1681 100644 --- a/backend/migrations/1794000000000_add-action-url-to-notifications.js +++ b/backend/migrations/1794000000000_add-action-url-to-notifications.js @@ -8,11 +8,11 @@ export const shorthands = undefined; * @returns {Promise | void} */ export const up = (pgm) => { - pgm.addColumns("notifications", { + pgm.addColumns('notifications', { action_url: { - type: "varchar(500)", + type: 'varchar(500)', notNull: false, - comment: "Deep-link URL to the relevant entity (loan, remittance, etc.)", + comment: 'Deep-link URL to the relevant entity (loan, remittance, etc.)', }, }); }; @@ -22,5 +22,5 @@ export const up = (pgm) => { * @returns {Promise | void} */ export const down = (pgm) => { - pgm.dropColumns("notifications", ["action_url"]); + pgm.dropColumns('notifications', ['action_url']); }; diff --git a/backend/package.json b/backend/package.json index d23fd427..af108ee2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -79,7 +79,6 @@ "typescript": "^5.9.3" }, "eslintConfig": {}, - "prettier": {}, "overrides": { "axios": "1.13.5" } diff --git a/backend/replace_logger.cjs b/backend/replace_logger.cjs index 3538cd35..57781055 100644 --- a/backend/replace_logger.cjs +++ b/backend/replace_logger.cjs @@ -1,27 +1,27 @@ -const fs = require("fs"); -const path = require("path"); +const fs = require('fs'); +const path = require('path'); const files = [ - "src/services/sorobanService.ts", - "src/controllers/loanController.ts", - "src/services/notificationService.ts", - "src/services/eventIndexer.ts", - "src/controllers/indexerController.ts", - "src/services/webhookService.ts", - "src/services/defaultChecker.ts", - "src/services/scoreReconciliationService.ts", - "src/services/eventStreamService.ts", - "src/services/cacheService.ts", - "src/services/webhookRetryScheduler.ts", - "src/services/indexerManager.ts", - "src/services/webhookRetryProcessor.ts", - "src/services/scoresService.ts", - "src/services/remittanceService.ts", - "src/services/rateLimitService.ts", - "src/controllers/remittanceController.ts", - "src/controllers/poolController.ts", - "src/controllers/eventStreamController.ts", - "src/controllers/notificationController.ts", + 'src/services/sorobanService.ts', + 'src/controllers/loanController.ts', + 'src/services/notificationService.ts', + 'src/services/eventIndexer.ts', + 'src/controllers/indexerController.ts', + 'src/services/webhookService.ts', + 'src/services/defaultChecker.ts', + 'src/services/scoreReconciliationService.ts', + 'src/services/eventStreamService.ts', + 'src/services/cacheService.ts', + 'src/services/webhookRetryScheduler.ts', + 'src/services/indexerManager.ts', + 'src/services/webhookRetryProcessor.ts', + 'src/services/scoresService.ts', + 'src/services/remittanceService.ts', + 'src/services/rateLimitService.ts', + 'src/controllers/remittanceController.ts', + 'src/controllers/poolController.ts', + 'src/controllers/eventStreamController.ts', + 'src/controllers/notificationController.ts', ]; for (const file of files) { @@ -30,13 +30,10 @@ for (const file of files) { console.error(`File not found: ${filePath}`); continue; } - let content = fs.readFileSync(filePath, "utf8"); + let content = fs.readFileSync(filePath, 'utf8'); // replace logger.info( -> logger.withContext().info( // and so on for warn, error - content = content.replace( - /\blogger\.(info|warn|error)\s*\(/g, - "logger.withContext().$1(", - ); - fs.writeFileSync(filePath, content, "utf8"); + content = content.replace(/\blogger\.(info|warn|error)\s*\(/g, 'logger.withContext().$1('); + fs.writeFileSync(filePath, content, 'utf8'); console.log(`Updated ${file}`); } diff --git a/backend/src/__tests__/adminDisputePagination.test.ts b/backend/src/__tests__/adminDisputePagination.test.ts index 2aff9015..e8fe4dda 100644 --- a/backend/src/__tests__/adminDisputePagination.test.ts +++ b/backend/src/__tests__/adminDisputePagination.test.ts @@ -1,5 +1,5 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import type { NextFunction, Request, Response } from "express"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; type MockQueryResult = { rows: Record[]; rowCount: number }; @@ -7,16 +7,14 @@ const mockQuery: jest.MockedFunction< (sql: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: mockQuery, getClient: jest.fn(), })); -const { listLoanDisputes } = - await import("../controllers/adminDisputeController.js"); +const { listLoanDisputes } = await import('../controllers/adminDisputeController.js'); -const flushAsync = async (): Promise => - new Promise((resolve) => setImmediate(resolve)); +const flushAsync = async (): Promise => new Promise((resolve) => setImmediate(resolve)); const createMockResponse = (): Response => ({ @@ -28,23 +26,23 @@ function disputeRow(id: number, status: string, created_at: string) { return { id, loan_id: 100 + id, - borrower: "GBORROWER", + borrower: 'GBORROWER', status, - reason: "Test reason", + reason: 'Test reason', created_at, }; } -describe("listLoanDisputes pagination", () => { +describe('listLoanDisputes pagination', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("returns disputes with default limit and status=open", async () => { + it('returns disputes with default limit and status=open', async () => { const rows = [ - disputeRow(3, "open", "2026-05-28T10:00:00.000Z"), - disputeRow(2, "open", "2026-05-27T10:00:00.000Z"), - disputeRow(1, "open", "2026-05-26T10:00:00.000Z"), + disputeRow(3, 'open', '2026-05-28T10:00:00.000Z'), + disputeRow(2, 'open', '2026-05-27T10:00:00.000Z'), + disputeRow(1, 'open', '2026-05-26T10:00:00.000Z'), ]; // LIMIT is default 50 + 1 = 51 — fewer rows than limit, no next page mockQuery.mockResolvedValueOnce({ rows, rowCount: 3 }); @@ -69,13 +67,9 @@ describe("listLoanDisputes pagination", () => { ); }); - it("returns next_cursor when there are more results than limit", async () => { + it('returns next_cursor when there are more results than limit', async () => { const rows = Array.from({ length: 51 }, (_, i) => - disputeRow( - 100 - i, - "open", - new Date(2026, 4, 28, 10, 0, 0, -i * 60_000).toISOString(), - ), + disputeRow(100 - i, 'open', new Date(2026, 4, 28, 10, 0, 0, -i * 60_000).toISOString()), ); // 51 rows for limit=50 + 1 check mockQuery.mockResolvedValueOnce({ rows, rowCount: 51 }); @@ -87,48 +81,38 @@ describe("listLoanDisputes pagination", () => { listLoanDisputes(req, res, next as unknown as NextFunction); await flushAsync(); - const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record< - string, - unknown - >; + const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record; expect(jsonCall.success).toBe(true); const pageInfo = jsonCall.page_info as Record; expect(pageInfo.has_next).toBe(true); - expect(typeof pageInfo.next_cursor).toBe("string"); + expect(typeof pageInfo.next_cursor).toBe('string'); expect((jsonCall.data as unknown[]).length).toBe(50); }); - it("enforces max page size (capped at 100)", async () => { + it('enforces max page size (capped at 100)', async () => { const rows = Array.from({ length: 100 }, (_, i) => - disputeRow( - i, - "open", - new Date(2026, 4, 28, 10, 0, 0, -i * 60_000).toISOString(), - ), + disputeRow(i, 'open', new Date(2026, 4, 28, 10, 0, 0, -i * 60_000).toISOString()), ); mockQuery.mockResolvedValueOnce({ rows, rowCount: 100 }); - const req = { query: { limit: "500" } } as unknown as Request; + const req = { query: { limit: '500' } } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); listLoanDisputes(req, res, next as unknown as NextFunction); await flushAsync(); - const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record< - string, - unknown - >; + const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record; const pageInfo = jsonCall.page_info as Record; // limit should be capped at 100 expect(pageInfo.limit).toBe(100); }); - it("filters by status correctly", async () => { - const rows = [disputeRow(1, "resolved", "2026-05-28T10:00:00.000Z")]; + it('filters by status correctly', async () => { + const rows = [disputeRow(1, 'resolved', '2026-05-28T10:00:00.000Z')]; mockQuery.mockResolvedValueOnce({ rows, rowCount: 1 }); - const req = { query: { status: "resolved" } } as unknown as Request; + const req = { query: { status: 'resolved' } } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -137,20 +121,20 @@ describe("listLoanDisputes pagination", () => { // Verify SQL includes status filter expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("WHERE status = $1"), - expect.arrayContaining(["resolved"]), + expect.stringContaining('WHERE status = $1'), + expect.arrayContaining(['resolved']), ); }); - it("includes all statuses when status=all", async () => { + it('includes all statuses when status=all', async () => { const rows = [ - disputeRow(3, "open", "2026-05-28T10:00:00.000Z"), - disputeRow(2, "resolved", "2026-05-27T10:00:00.000Z"), - disputeRow(1, "rejected", "2026-05-26T10:00:00.000Z"), + disputeRow(3, 'open', '2026-05-28T10:00:00.000Z'), + disputeRow(2, 'resolved', '2026-05-27T10:00:00.000Z'), + disputeRow(1, 'rejected', '2026-05-26T10:00:00.000Z'), ]; mockQuery.mockResolvedValueOnce({ rows, rowCount: 3 }); - const req = { query: { status: "all" } } as unknown as Request; + const req = { query: { status: 'all' } } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -159,20 +143,20 @@ describe("listLoanDisputes pagination", () => { // No WHERE clause for status expect(mockQuery).toHaveBeenCalledWith( - expect.not.stringContaining("WHERE status"), + expect.not.stringContaining('WHERE status'), expect.any(Array), ); }); - it("uses cursor pagination when cursor is provided", async () => { + it('uses cursor pagination when cursor is provided', async () => { const rows = [ - disputeRow(2, "open", "2026-05-27T10:00:00.000Z"), - disputeRow(1, "open", "2026-05-26T10:00:00.000Z"), + disputeRow(2, 'open', '2026-05-27T10:00:00.000Z'), + disputeRow(1, 'open', '2026-05-26T10:00:00.000Z'), ]; mockQuery.mockResolvedValueOnce({ rows, rowCount: 2 }); const req = { - query: { cursor: "2026-05-28T10:00:00.000Z" }, + query: { cursor: '2026-05-28T10:00:00.000Z' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -181,12 +165,12 @@ describe("listLoanDisputes pagination", () => { await flushAsync(); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("created_at < $2"), - expect.arrayContaining(["2026-05-28T10:00:00.000Z"]), + expect.stringContaining('created_at < $2'), + expect.arrayContaining(['2026-05-28T10:00:00.000Z']), ); }); - it("orders newest-first by default", async () => { + it('orders newest-first by default', async () => { mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); const req = { query: {} } as unknown as Request; @@ -197,7 +181,7 @@ describe("listLoanDisputes pagination", () => { await flushAsync(); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("ORDER BY created_at DESC"), + expect.stringContaining('ORDER BY created_at DESC'), expect.any(Array), ); }); diff --git a/backend/src/__tests__/adminReindex.test.ts b/backend/src/__tests__/adminReindex.test.ts index b95309bb..c2e5b7d7 100644 --- a/backend/src/__tests__/adminReindex.test.ts +++ b/backend/src/__tests__/adminReindex.test.ts @@ -1,51 +1,49 @@ -import request from "supertest"; -import app from "../app.js"; +import request from 'supertest'; +import app from '../app.js'; -describe("Admin reindex endpoint", () => { - const apiKey = "test-internal-api-key"; +describe('Admin reindex endpoint', () => { + const apiKey = 'test-internal-api-key'; beforeAll(() => { process.env.INTERNAL_API_KEY = apiKey; }); - it("rejects requests without API key", async () => { - const response = await request(app).post( - "/api/admin/reindex?fromLedger=1&toLedger=2", - ); + it('rejects requests without API key', async () => { + const response = await request(app).post('/api/admin/reindex?fromLedger=1&toLedger=2'); expect(response.status).toBe(401); }); - it("validates ledger range query parameters", async () => { + it('validates ledger range query parameters', async () => { const response = await request(app) - .post("/api/admin/reindex?fromLedger=abc&toLedger=2") - .set("x-api-key", apiKey); + .post('/api/admin/reindex?fromLedger=abc&toLedger=2') + .set('x-api-key', apiKey); expect(response.status).toBe(400); expect(response.body.success).toBe(false); }); - it("rejects quarantine list requests without API key", async () => { - const response = await request(app).get("/api/admin/quarantine-events"); + it('rejects quarantine list requests without API key', async () => { + const response = await request(app).get('/api/admin/quarantine-events'); expect(response.status).toBe(401); }); - it("validates reprocess payload ids", async () => { + it('validates reprocess payload ids', async () => { const response = await request(app) - .post("/api/admin/quarantine-events/reprocess") - .set("x-api-key", apiKey) - .send({ ids: [1, "bad-id"] }); + .post('/api/admin/quarantine-events/reprocess') + .set('x-api-key', apiKey) + .send({ ids: [1, 'bad-id'] }); expect(response.status).toBe(400); expect(response.body.success).toBe(false); }); - it("rejects check-defaults payloads with more than 1000 loan IDs", async () => { + it('rejects check-defaults payloads with more than 1000 loan IDs', async () => { const loanIds = Array.from({ length: 1001 }, (_, index) => index + 1); const response = await request(app) - .post("/api/admin/check-defaults") - .set("x-api-key", apiKey) + .post('/api/admin/check-defaults') + .set('x-api-key', apiKey) .send({ loanIds }); expect(response.status).toBe(400); diff --git a/backend/src/__tests__/apiKeyScopes.test.ts b/backend/src/__tests__/apiKeyScopes.test.ts index e1f07b5c..54da6fb7 100644 --- a/backend/src/__tests__/apiKeyScopes.test.ts +++ b/backend/src/__tests__/apiKeyScopes.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; -import type { Request, Response, NextFunction } from "express"; -import { AppError } from "../errors/AppError.js"; +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import type { Request, Response, NextFunction } from 'express'; +import { AppError } from '../errors/AppError.js'; const originalEnv = process.env.INTERNAL_API_KEY; function makeReq(apiKey?: string): Partial { return { - headers: apiKey ? { "x-api-key": apiKey } : {}, + headers: apiKey ? { 'x-api-key': apiKey } : {}, }; } @@ -24,11 +24,11 @@ function makeNext(): NextFunction & { calls: unknown[][] } { async function loadMiddleware() { // Force module re-evaluation so env changes take effect - const mod = await import("../middleware/auth.js"); + const mod = await import('../middleware/auth.js'); return mod.requireApiKey; } -describe("requireApiKey – scope support", () => { +describe('requireApiKey – scope support', () => { afterEach(() => { if (originalEnv === undefined) { delete process.env.INTERNAL_API_KEY; @@ -37,28 +37,24 @@ describe("requireApiKey – scope support", () => { } }); - describe("legacy key (no scope)", () => { + describe('legacy key (no scope)', () => { beforeEach(() => { - process.env.INTERNAL_API_KEY = "legacy-value"; + process.env.INTERNAL_API_KEY = 'legacy-value'; }); - it("is accepted on a route with no required scope", async () => { + it('is accepted on a route with no required scope', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey()( - makeReq("legacy-value") as Request, - makeRes() as Response, - next, - ); + requireApiKey()(makeReq('legacy-value') as Request, makeRes() as Response, next); expect(next.calls.length).toBe(1); expect(next.calls[0]![0]).toBeUndefined(); }); - it("is accepted on a route with admin:disputes scope", async () => { + it('is accepted on a route with admin:disputes scope', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey("admin:disputes")( - makeReq("legacy-value") as Request, + requireApiKey('admin:disputes')( + makeReq('legacy-value') as Request, makeRes() as Response, next, ); @@ -66,29 +62,25 @@ describe("requireApiKey – scope support", () => { expect(next.calls[0]![0]).toBeUndefined(); }); - it("is accepted on any admin scope (admin:loans)", async () => { + it('is accepted on any admin scope (admin:loans)', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey("admin:loans")( - makeReq("legacy-value") as Request, - makeRes() as Response, - next, - ); + requireApiKey('admin:loans')(makeReq('legacy-value') as Request, makeRes() as Response, next); expect(next.calls.length).toBe(1); expect(next.calls[0]![0]).toBeUndefined(); }); }); - describe("scoped key", () => { + describe('scoped key', () => { beforeEach(() => { - process.env.INTERNAL_API_KEY = "admin:disputes:dispute-value"; + process.env.INTERNAL_API_KEY = 'admin:disputes:dispute-value'; }); - it("is accepted on a matching scope", async () => { + it('is accepted on a matching scope', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey("admin:disputes")( - makeReq("dispute-value") as Request, + requireApiKey('admin:disputes')( + makeReq('dispute-value') as Request, makeRes() as Response, next, ); @@ -96,29 +88,25 @@ describe("requireApiKey – scope support", () => { expect(next.calls[0]![0]).toBeUndefined(); }); - it("is rejected on a route with no explicit scope", async () => { + it('is rejected on a route with no explicit scope', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); expect(() => - requireApiKey()( - makeReq("dispute-value") as Request, - makeRes() as Response, - next, - ), + requireApiKey()(makeReq('dispute-value') as Request, makeRes() as Response, next), ).toThrow(AppError); expect(next.calls.length).toBe(0); }); - it("throws 403 on a different scope (admin:loans)", async () => { + it('throws 403 on a different scope (admin:loans)', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); try { - requireApiKey("admin:loans")( - makeReq("dispute-value") as Request, + requireApiKey('admin:loans')( + makeReq('dispute-value') as Request, makeRes() as Response, next, ); - throw new Error("expected middleware to throw"); + throw new Error('expected middleware to throw'); } catch (error) { expect(error).toBeInstanceOf(AppError); expect((error as AppError).statusCode).toBe(403); @@ -126,64 +114,60 @@ describe("requireApiKey – scope support", () => { expect(next.calls.length).toBe(0); }); - it("throws when key is absent", async () => { + it('throws when key is absent', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); expect(() => - requireApiKey("admin:disputes")( - makeReq() as Request, - makeRes() as Response, - next, - ), + requireApiKey('admin:disputes')(makeReq() as Request, makeRes() as Response, next), ).toThrow(); }); }); - describe("multiple keys configured", () => { + describe('multiple keys configured', () => { beforeEach(() => { process.env.INTERNAL_API_KEY = - "admin:disputes:dispute-one,admin:indexer:indexer-two,legacy-value"; + 'admin:disputes:dispute-one,admin:indexer:indexer-two,legacy-value'; }); - it("accepts dispute-one for admin:disputes", async () => { + it('accepts dispute-one for admin:disputes', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey("admin:disputes")( - makeReq("dispute-one") as Request, + requireApiKey('admin:disputes')( + makeReq('dispute-one') as Request, makeRes() as Response, next, ); expect(next.calls[0]![0]).toBeUndefined(); }); - it("accepts indexer-two for admin:indexer", async () => { + it('accepts indexer-two for admin:indexer', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey("admin:indexer")( - makeReq("indexer-two") as Request, + requireApiKey('admin:indexer')( + makeReq('indexer-two') as Request, makeRes() as Response, next, ); expect(next.calls[0]![0]).toBeUndefined(); }); - it("rejects dispute-one for admin:indexer", async () => { + it('rejects dispute-one for admin:indexer', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); expect(() => - requireApiKey("admin:indexer")( - makeReq("dispute-one") as Request, + requireApiKey('admin:indexer')( + makeReq('dispute-one') as Request, makeRes() as Response, next, ), ).toThrow(); }); - it("accepts legacy key for admin:webhooks", async () => { + it('accepts legacy key for admin:webhooks', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); - requireApiKey("admin:webhooks")( - makeReq("legacy-value") as Request, + requireApiKey('admin:webhooks')( + makeReq('legacy-value') as Request, makeRes() as Response, next, ); @@ -191,39 +175,31 @@ describe("requireApiKey – scope support", () => { }); }); - describe("constant-time comparison", () => { + describe('constant-time comparison', () => { beforeEach(() => { - process.env.INTERNAL_API_KEY = "correct-value"; + process.env.INTERNAL_API_KEY = 'correct-value'; }); - it("rejects a wrong key that has the same length as the correct key", async () => { + it('rejects a wrong key that has the same length as the correct key', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); expect(() => - requireApiKey()( - makeReq("wrong-valueXX") as Request, - makeRes() as Response, - next, - ), + requireApiKey()(makeReq('wrong-valueXX') as Request, makeRes() as Response, next), ).toThrow(); expect(next.calls.length).toBe(0); }); }); - describe("INTERNAL_API_KEY not set", () => { + describe('INTERNAL_API_KEY not set', () => { beforeEach(() => { delete process.env.INTERNAL_API_KEY; }); - it("throws an internal error", async () => { + it('throws an internal error', async () => { const requireApiKey = await loadMiddleware(); const next = makeNext(); expect(() => - requireApiKey()( - makeReq("any-value") as Request, - makeRes() as Response, - next, - ), + requireApiKey()(makeReq('any-value') as Request, makeRes() as Response, next), ).toThrow(); }); }); diff --git a/backend/src/__tests__/apiV1Mounts.test.ts b/backend/src/__tests__/apiV1Mounts.test.ts index 160b9b68..b46df429 100644 --- a/backend/src/__tests__/apiV1Mounts.test.ts +++ b/backend/src/__tests__/apiV1Mounts.test.ts @@ -1,13 +1,13 @@ -import request from "supertest"; -import { jest } from "@jest/globals"; -import { generateJwtToken } from "../services/authService.js"; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { generateJwtToken } from '../services/authService.js'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -const VALID_API_KEY = "test-internal-key"; -const LENDER_WALLET = "GAAAALENDER123456789"; +const VALID_API_KEY = 'test-internal-key'; +const LENDER_WALLET = 'GAAAALENDER123456789'; -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; process.env.INTERNAL_API_KEY = VALID_API_KEY; process.env.LENDER_WALLETS = LENDER_WALLET; @@ -15,7 +15,7 @@ process.env.LENDER_WALLETS = LENDER_WALLET; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -24,11 +24,10 @@ jest.unstable_mockModule("../db/connection.js", () => ({ })); // ── notificationService mock ───────────────────────────────────────────────── -const mockGetNotificationsForUser = - jest.fn<(...args: unknown[]) => Promise>(); +const mockGetNotificationsForUser = jest.fn<(...args: unknown[]) => Promise>(); const mockGetUnreadCount = jest.fn<(...args: unknown[]) => Promise>(); const mockSubscribe = jest.fn(); -jest.unstable_mockModule("../services/notificationService.js", () => ({ +jest.unstable_mockModule('../services/notificationService.js', () => ({ notificationService: { getNotificationsForUser: mockGetNotificationsForUser, getUnreadCount: mockGetUnreadCount, @@ -40,7 +39,7 @@ jest.unstable_mockModule("../services/notificationService.js", () => ({ // ── eventStreamService mock ────────────────────────────────────────────────── const mockGetConnectionCount = jest.fn(); -jest.unstable_mockModule("../services/eventStreamService.js", () => ({ +jest.unstable_mockModule('../services/eventStreamService.js', () => ({ eventStreamService: { getConnectionCount: mockGetConnectionCount, subscribeBorrower: jest.fn(), @@ -48,10 +47,10 @@ jest.unstable_mockModule("../services/eventStreamService.js", () => ({ }, })); -await import("../db/connection.js"); -await import("../services/notificationService.js"); -await import("../services/eventStreamService.js"); -const { default: app } = await import("../app.js"); +await import('../db/connection.js'); +await import('../services/notificationService.js'); +await import('../services/eventStreamService.js'); +const { default: app } = await import('../app.js'); const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, @@ -70,37 +69,35 @@ afterAll(() => { // --------------------------------------------------------------------------- // /api/v1 route mounts // --------------------------------------------------------------------------- -describe("/api/v1 route mounts", () => { - it("GET /api/v1/pool/stats returns 200 with lender auth", async () => { +describe('/api/v1 route mounts', () => { + it('GET /api/v1/pool/stats returns 200 with lender auth', async () => { mockQuery .mockResolvedValueOnce({ - rows: [{ total_deposits: "10000" }], + rows: [{ total_deposits: '10000' }], }) .mockResolvedValueOnce({ - rows: [{ active_loans_count: "3", total_outstanding: "5000" }], + rows: [{ active_loans_count: '3', total_outstanding: '5000' }], }); - const response = await request(app) - .get("/api/v1/pool/stats") - .set(bearer(LENDER_WALLET)); + const response = await request(app).get('/api/v1/pool/stats').set(bearer(LENDER_WALLET)); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); - it("GET /api/v1/notifications returns 200 with borrower auth", async () => { + it('GET /api/v1/notifications returns 200 with borrower auth', async () => { mockGetNotificationsForUser.mockResolvedValueOnce([]); mockGetUnreadCount.mockResolvedValueOnce(0); const response = await request(app) - .get("/api/v1/notifications") - .set(bearer("GAAABORROWER123456789")); + .get('/api/v1/notifications') + .set(bearer('GAAABORROWER123456789')); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); - it("GET /api/v1/events/status returns 200 with API key", async () => { + it('GET /api/v1/events/status returns 200 with API key', async () => { mockGetConnectionCount.mockReturnValueOnce({ borrower: 0, admin: 0, @@ -108,8 +105,8 @@ describe("/api/v1 route mounts", () => { }); const response = await request(app) - .get("/api/v1/events/status") - .set("x-api-key", VALID_API_KEY); + .get('/api/v1/events/status') + .set('x-api-key', VALID_API_KEY); expect(response.status).toBe(200); expect(response.body.success).toBe(true); diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index f5fdd19a..5ccd8b85 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -1,64 +1,59 @@ -import { describe, it, expect, beforeAll } from "@jest/globals"; -import request from "supertest"; -import app from "../app.js"; -import { Keypair } from "@stellar/stellar-sdk"; +import { describe, it, expect, beforeAll } from '@jest/globals'; +import request from 'supertest'; +import app from '../app.js'; +import { Keypair } from '@stellar/stellar-sdk'; -describe("Auth API", () => { +describe('Auth API', () => { beforeAll(() => { - process.env.JWT_SECRET = "test-secret-key-for-jest"; + process.env.JWT_SECRET = 'test-secret-key-for-jest'; }); - describe("POST /api/auth/challenge", () => { - it("should generate a challenge for a valid public key", async () => { + describe('POST /api/auth/challenge', () => { + it('should generate a challenge for a valid public key', async () => { const keypair = Keypair.random(); const response = await request(app) - .post("/api/auth/challenge") + .post('/api/auth/challenge') .send({ publicKey: keypair.publicKey() }) .expect(200); expect(response.body.success).toBe(true); - expect(response.body.data.message).toContain("Sign this message"); + expect(response.body.data.message).toContain('Sign this message'); expect(response.body.data.nonce).toBeDefined(); expect(response.body.data.timestamp).toBeDefined(); expect(response.body.data.expiresIn).toBe(5 * 60 * 1000); }); - it("should reject invalid public key", async () => { + it('should reject invalid public key', async () => { const response = await request(app) - .post("/api/auth/challenge") - .send({ publicKey: "invalid-key" }) + .post('/api/auth/challenge') + .send({ publicKey: 'invalid-key' }) .expect(400); expect(response.body.success).toBe(false); }); - it("should reject missing public key", async () => { - const response = await request(app) - .post("/api/auth/challenge") - .send({}) - .expect(400); + it('should reject missing public key', async () => { + const response = await request(app).post('/api/auth/challenge').send({}).expect(400); expect(response.body.success).toBe(false); }); }); - describe("POST /api/auth/login", () => { - it("should login with valid signature", async () => { + describe('POST /api/auth/login', () => { + it('should login with valid signature', async () => { const keypair = Keypair.random(); const challengeResponse = await request(app) - .post("/api/auth/challenge") + .post('/api/auth/challenge') .send({ publicKey: keypair.publicKey() }) .expect(200); const message = challengeResponse.body.data.message; - const signature = keypair - .sign(Buffer.from(message, "utf-8")) - .toString("base64"); + const signature = keypair.sign(Buffer.from(message, 'utf-8')).toString('base64'); const loginResponse = await request(app) - .post("/api/auth/login") + .post('/api/auth/login') .send({ publicKey: keypair.publicKey(), message, @@ -71,22 +66,22 @@ describe("Auth API", () => { expect(loginResponse.body.data.publicKey).toBe(keypair.publicKey()); }); - it("should reject invalid signature", async () => { + it('should reject invalid signature', async () => { const keypair = Keypair.random(); const differentKeypair = Keypair.random(); const challengeResponse = await request(app) - .post("/api/auth/challenge") + .post('/api/auth/challenge') .send({ publicKey: keypair.publicKey() }) .expect(200); const message = challengeResponse.body.data.message; const wrongSignature = differentKeypair - .sign(Buffer.from(message, "utf-8")) - .toString("base64"); + .sign(Buffer.from(message, 'utf-8')) + .toString('base64'); const loginResponse = await request(app) - .post("/api/auth/login") + .post('/api/auth/login') .send({ publicKey: keypair.publicKey(), message, @@ -97,32 +92,27 @@ describe("Auth API", () => { expect(loginResponse.body.success).toBe(false); }); - it("should reject missing fields", async () => { - const response = await request(app) - .post("/api/auth/login") - .send({}) - .expect(400); + it('should reject missing fields', async () => { + const response = await request(app).post('/api/auth/login').send({}).expect(400); expect(response.body.success).toBe(false); }); }); - describe("GET /api/auth/verify", () => { - it("should verify valid token", async () => { + describe('GET /api/auth/verify', () => { + it('should verify valid token', async () => { const keypair = Keypair.random(); const challengeResponse = await request(app) - .post("/api/auth/challenge") + .post('/api/auth/challenge') .send({ publicKey: keypair.publicKey() }) .expect(200); const message = challengeResponse.body.data.message; - const signature = keypair - .sign(Buffer.from(message, "utf-8")) - .toString("base64"); + const signature = keypair.sign(Buffer.from(message, 'utf-8')).toString('base64'); const loginResponse = await request(app) - .post("/api/auth/login") + .post('/api/auth/login') .send({ publicKey: keypair.publicKey(), message, @@ -133,36 +123,36 @@ describe("Auth API", () => { const token = loginResponse.body.data.token; const verifyResponse = await request(app) - .get("/api/auth/verify") - .set("Authorization", `Bearer ${token}`) + .get('/api/auth/verify') + .set('Authorization', `Bearer ${token}`) .expect(200); expect(verifyResponse.body.success).toBe(true); expect(verifyResponse.body.data.valid).toBe(true); expect(verifyResponse.body.data.publicKey).toBe(keypair.publicKey()); - expect(verifyResponse.body.data.role).toBe("borrower"); + expect(verifyResponse.body.data.role).toBe('borrower'); expect(Array.isArray(verifyResponse.body.data.scopes)).toBe(true); - expect(verifyResponse.body.data.scopes).toContain("read:loans"); + expect(verifyResponse.body.data.scopes).toContain('read:loans'); }); - it("should reject missing token", async () => { - const response = await request(app).get("/api/auth/verify").expect(401); + it('should reject missing token', async () => { + const response = await request(app).get('/api/auth/verify').expect(401); expect(response.body.success).toBe(false); }); - it("should reject invalid token", async () => { + it('should reject invalid token', async () => { const response = await request(app) - .get("/api/auth/verify") - .set("Authorization", "Bearer invalid-token") + .get('/api/auth/verify') + .set('Authorization', 'Bearer invalid-token') .expect(401); expect(response.body.success).toBe(false); }); }); - describe("Rate limiting", () => { - it("should return 429 after 10 challenge requests from same IP", async () => { + describe('Rate limiting', () => { + it('should return 429 after 10 challenge requests from same IP', async () => { const keypair = Keypair.random(); let lastResponse: { status: number; @@ -175,15 +165,15 @@ describe("Auth API", () => { }; for (let i = 0; i < 11; i++) { lastResponse = await request(app) - .post("/api/auth/challenge") - .set("X-Forwarded-For", "1.2.3.4") + .post('/api/auth/challenge') + .set('X-Forwarded-For', '1.2.3.4') .send({ publicKey: keypair.publicKey() }); } expect(lastResponse.status).toBe(429); expect(lastResponse.body.success).toBe(false); }); - it("should return 429 and Retry-After after 5 login attempts from same IP", async () => { + it('should return 429 and Retry-After after 5 login attempts from same IP', async () => { const keypair = Keypair.random(); let lastResponse: { status: number; @@ -196,19 +186,19 @@ describe("Auth API", () => { }; for (let i = 0; i < 6; i++) { lastResponse = await request(app) - .post("/api/auth/login") - .set("X-Forwarded-For", "5.6.7.8") + .post('/api/auth/login') + .set('X-Forwarded-For', '5.6.7.8') .send({ publicKey: keypair.publicKey(), - message: "fake-message", - signature: "fake-signature", + message: 'fake-message', + signature: 'fake-signature', }); } expect(lastResponse.status).toBe(429); - expect(lastResponse.headers["retry-after"]).toBeDefined(); + expect(lastResponse.headers['retry-after']).toBeDefined(); }); - it("should return 429 after 5 login attempts with same public key", async () => { + it('should return 429 after 5 login attempts with same public key', async () => { const keypair = Keypair.random(); let lastResponse: { status: number; @@ -221,12 +211,12 @@ describe("Auth API", () => { }; for (let i = 0; i < 6; i++) { lastResponse = await request(app) - .post("/api/auth/login") - .set("X-Forwarded-For", `9.9.9.${i}`) + .post('/api/auth/login') + .set('X-Forwarded-For', `9.9.9.${i}`) .send({ publicKey: keypair.publicKey(), - message: "fake-message", - signature: "fake-signature", + message: 'fake-message', + signature: 'fake-signature', }); } expect(lastResponse.status).toBe(429); diff --git a/backend/src/__tests__/cacheInvalidation.test.ts b/backend/src/__tests__/cacheInvalidation.test.ts index 60b0fa2e..0f5e7ef1 100644 --- a/backend/src/__tests__/cacheInvalidation.test.ts +++ b/backend/src/__tests__/cacheInvalidation.test.ts @@ -1,4 +1,4 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; /** * Tests that each write path busts the correct cache keys. @@ -7,25 +7,19 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals"; * right key). */ -const mockDelete = jest - .fn<(key: string) => Promise>() - .mockResolvedValue(undefined); +const mockDelete = jest.fn<(key: string) => Promise>().mockResolvedValue(undefined); const mockSet = jest .fn<(key: string, value: unknown) => Promise>() .mockResolvedValue(undefined); -const mockGet = jest - .fn<(key: string) => Promise>() - .mockResolvedValue(null); +const mockGet = jest.fn<(key: string) => Promise>().mockResolvedValue(null); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: mockGet, set: mockSet, delete: mockDelete, - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), - invalidatePattern: jest - .fn<() => Promise>() - .mockResolvedValue(undefined), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), + invalidatePattern: jest.fn<() => Promise>().mockResolvedValue(undefined), }, })); @@ -35,39 +29,35 @@ const { invalidateOnLoanRequest, invalidateOnDeposit, invalidateOnWithdraw, -} = await import("../utils/cacheKeys.js"); +} = await import('../utils/cacheKeys.js'); -const BORROWER = "Gborrower123"; -const DEPOSITOR = "GDEPOSITOR456"; +const BORROWER = 'Gborrower123'; +const DEPOSITOR = 'GDEPOSITOR456'; const LOAN_ID = 42; -describe("cacheKeys helpers", () => { +describe('cacheKeys helpers', () => { beforeEach(() => { mockDelete.mockClear(); mockSet.mockClear(); mockGet.mockClear(); }); - describe("CacheKeys generators", () => { - it("poolStats returns a stable key", () => { - expect(CacheKeys.poolStats()).toBe("pool:stats"); + describe('CacheKeys generators', () => { + it('poolStats returns a stable key', () => { + expect(CacheKeys.poolStats()).toBe('pool:stats'); }); - it("borrowerLoans encodes the borrower address", () => { - expect(CacheKeys.borrowerLoans(BORROWER)).toBe( - `borrower:loans:${BORROWER}`, - ); + it('borrowerLoans encodes the borrower address', () => { + expect(CacheKeys.borrowerLoans(BORROWER)).toBe(`borrower:loans:${BORROWER}`); }); - it("scoreBreakdown encodes the public key", () => { - expect(CacheKeys.scoreBreakdown(BORROWER)).toBe( - `score:breakdown:${BORROWER}`, - ); + it('scoreBreakdown encodes the public key', () => { + expect(CacheKeys.scoreBreakdown(BORROWER)).toBe(`score:breakdown:${BORROWER}`); }); }); - describe("invalidateOnRepay", () => { - it("deletes pool stats, borrower loans, and score breakdown", async () => { + describe('invalidateOnRepay', () => { + it('deletes pool stats, borrower loans, and score breakdown', async () => { await invalidateOnRepay(BORROWER, LOAN_ID); const deletedKeys = mockDelete.mock.calls.map((c) => c[0]); @@ -76,14 +66,14 @@ describe("cacheKeys helpers", () => { expect(deletedKeys).toContain(CacheKeys.scoreBreakdown(BORROWER)); }); - it("calls delete exactly 3 times", async () => { + it('calls delete exactly 3 times', async () => { await invalidateOnRepay(BORROWER, LOAN_ID); expect(mockDelete).toHaveBeenCalledTimes(3); }); }); - describe("invalidateOnLoanRequest", () => { - it("deletes pool stats and borrower loans", async () => { + describe('invalidateOnLoanRequest', () => { + it('deletes pool stats and borrower loans', async () => { await invalidateOnLoanRequest(BORROWER); const deletedKeys = mockDelete.mock.calls.map((c) => c[0]); @@ -91,40 +81,40 @@ describe("cacheKeys helpers", () => { expect(deletedKeys).toContain(CacheKeys.borrowerLoans(BORROWER)); }); - it("calls delete exactly 2 times", async () => { + it('calls delete exactly 2 times', async () => { await invalidateOnLoanRequest(BORROWER); expect(mockDelete).toHaveBeenCalledTimes(2); }); }); - describe("invalidateOnDeposit", () => { - it("deletes pool stats", async () => { + describe('invalidateOnDeposit', () => { + it('deletes pool stats', async () => { await invalidateOnDeposit(DEPOSITOR); expect(mockDelete).toHaveBeenCalledWith(CacheKeys.poolStats()); }); - it("calls delete exactly once", async () => { + it('calls delete exactly once', async () => { await invalidateOnDeposit(DEPOSITOR); expect(mockDelete).toHaveBeenCalledTimes(1); }); }); - describe("invalidateOnWithdraw", () => { - it("deletes pool stats", async () => { + describe('invalidateOnWithdraw', () => { + it('deletes pool stats', async () => { await invalidateOnWithdraw(DEPOSITOR); expect(mockDelete).toHaveBeenCalledWith(CacheKeys.poolStats()); }); - it("calls delete exactly once", async () => { + it('calls delete exactly once', async () => { await invalidateOnWithdraw(DEPOSITOR); expect(mockDelete).toHaveBeenCalledTimes(1); }); }); - describe("cache warm → write → recompute flow", () => { - it("next get returns null after invalidateOnRepay flushes the key", async () => { + describe('cache warm → write → recompute flow', () => { + it('next get returns null after invalidateOnRepay flushes the key', async () => { // Simulate a warmed cache for borrower loans const cache: Record = { [CacheKeys.borrowerLoans(BORROWER)]: { loans: [] }, diff --git a/backend/src/__tests__/cors.test.ts b/backend/src/__tests__/cors.test.ts index 57bc5b16..365c6435 100644 --- a/backend/src/__tests__/cors.test.ts +++ b/backend/src/__tests__/cors.test.ts @@ -1,20 +1,15 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import request from 'supertest'; jest.setTimeout(60000); const loadApp = async () => { jest.resetModules(); const mockQuery = jest - .fn< - ( - sql: string, - params?: unknown[], - ) => Promise<{ rows: unknown[]; rowCount: number }> - >() + .fn<(sql: string, params?: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>() .mockResolvedValue({ rows: [], rowCount: 0 }); - jest.unstable_mockModule("../db/connection.js", () => ({ + jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery, }, @@ -24,28 +19,28 @@ const loadApp = async () => { withTransaction: jest.fn(), })); - jest.unstable_mockModule("../services/cacheService.js", () => ({ + jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); - jest.unstable_mockModule("../services/sorobanService.js", () => ({ + jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); - return import("../app.js"); + return import('../app.js'); }; -describe("CORS middleware", () => { +describe('CORS middleware', () => { const originalEnv = { ...process.env }; beforeEach(() => { process.env = { ...originalEnv }; - process.env.NODE_ENV = "production"; - process.env.FRONTEND_URL = "https://frontend.example.com"; + process.env.NODE_ENV = 'production'; + process.env.FRONTEND_URL = 'https://frontend.example.com'; delete process.env.CORS_ALLOWED_ORIGINS; }); @@ -53,30 +48,26 @@ describe("CORS middleware", () => { process.env = originalEnv; }); - it("allows the configured frontend origin", async () => { + it('allows the configured frontend origin', async () => { const { default: app } = await loadApp(); const response = await request(app) - .get("/health") - .set("Origin", "https://frontend.example.com"); + .get('/health') + .set('Origin', 'https://frontend.example.com'); expect(response.status).toBe(200); - expect(response.headers["access-control-allow-origin"]).toBe( - "https://frontend.example.com", - ); - expect(response.headers["access-control-allow-credentials"]).toBe("true"); + expect(response.headers['access-control-allow-origin']).toBe('https://frontend.example.com'); + expect(response.headers['access-control-allow-credentials']).toBe('true'); }); - it("rejects unknown origins in production", async () => { + it('rejects unknown origins in production', async () => { const { default: app } = await loadApp(); const response = await request(app) - .get("/health") - .set("Origin", "https://malicious.example.com"); + .get('/health') + .set('Origin', 'https://malicious.example.com'); expect(response.status).toBe(403); - expect(response.body.error?.message).toBe( - "Origin is not allowed by CORS policy", - ); + expect(response.body.error?.message).toBe('Origin is not allowed by CORS policy'); }); }); diff --git a/backend/src/__tests__/database.test.ts b/backend/src/__tests__/database.test.ts index e1382f43..84ccd296 100644 --- a/backend/src/__tests__/database.test.ts +++ b/backend/src/__tests__/database.test.ts @@ -1,17 +1,17 @@ -import { describe, it, expect, beforeAll, afterAll } from "@jest/globals"; +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { UserProfileService, LoanHistoryService, IndexedEventsService, DatabaseService, -} from "../services/databaseService.js"; -import { query } from "../db/connection.js"; +} from '../services/databaseService.js'; +import { query } from '../db/connection.js'; let databaseAvailable = false; beforeAll(async () => { try { - await query("SELECT 1"); + await query('SELECT 1'); databaseAvailable = true; } catch { databaseAvailable = false; @@ -26,7 +26,7 @@ const describeIf = (name: string, fn: () => void) => { } }; -describeIf("Database Services", () => { +describeIf('Database Services', () => { beforeAll(async () => { try { await query(` @@ -81,75 +81,73 @@ describeIf("Database Services", () => { ) `); } catch (error) { - console.error("Migration error:", error); + console.error('Migration error:', error); } }); afterAll(async () => { try { - await query("DROP TABLE IF EXISTS user_profiles"); - await query("DROP TABLE IF EXISTS loan_history"); - await query("DROP TABLE IF EXISTS indexed_events"); + await query('DROP TABLE IF EXISTS user_profiles'); + await query('DROP TABLE IF EXISTS loan_history'); + await query('DROP TABLE IF EXISTS indexed_events'); } catch (error) { - console.error("Cleanup error:", error); + console.error('Cleanup error:', error); } }); - describe("UserProfileService", () => { - const testPublicKey = "G_TEST_PUBLIC_KEY_12345"; + describe('UserProfileService', () => { + const testPublicKey = 'G_TEST_PUBLIC_KEY_12345'; afterAll(async () => { - await query("DELETE FROM user_profiles WHERE public_key LIKE $1", [ - "G_TEST%", - ]); + await query('DELETE FROM user_profiles WHERE public_key LIKE $1', ['G_TEST%']); }); - it("should create a user profile", async () => { + it('should create a user profile', async () => { const profile = await UserProfileService.create({ public_key: testPublicKey, - display_name: "Test User", - email: "test@example.com", + display_name: 'Test User', + email: 'test@example.com', }); expect(profile).toBeDefined(); expect(profile.public_key).toBe(testPublicKey); - expect(profile.display_name).toBe("Test User"); - expect(profile.email).toBe("test@example.com"); + expect(profile.display_name).toBe('Test User'); + expect(profile.email).toBe('test@example.com'); }); - it("should find profile by public key", async () => { + it('should find profile by public key', async () => { const profile = await UserProfileService.findByPublicKey(testPublicKey); expect(profile).toBeDefined(); expect(profile?.public_key).toBe(testPublicKey); }); - it("should update a user profile", async () => { + it('should update a user profile', async () => { const updated = await UserProfileService.update(testPublicKey, { - display_name: "Updated Name", + display_name: 'Updated Name', }); expect(updated).toBeDefined(); - expect(updated?.display_name).toBe("Updated Name"); + expect(updated?.display_name).toBe('Updated Name'); }); - it("should upsert a user profile", async () => { + it('should upsert a user profile', async () => { const profile = await UserProfileService.upsert({ - public_key: "G_TEST_UPSERT_KEY", - display_name: "Upserted User", + public_key: 'G_TEST_UPSERT_KEY', + display_name: 'Upserted User', }); expect(profile).toBeDefined(); - expect(profile.public_key).toBe("G_TEST_UPSERT_KEY"); + expect(profile.public_key).toBe('G_TEST_UPSERT_KEY'); const updated = await UserProfileService.upsert({ - public_key: "G_TEST_UPSERT_KEY", - display_name: "Updated Upserted User", + public_key: 'G_TEST_UPSERT_KEY', + display_name: 'Updated Upserted User', }); - expect(updated.display_name).toBe("Updated Upserted User"); + expect(updated.display_name).toBe('Updated Upserted User'); }); - it("should delete a user profile", async () => { - const deleteKey = "G_TEST_DELETE_KEY"; + it('should delete a user profile', async () => { + const deleteKey = 'G_TEST_DELETE_KEY'; await UserProfileService.create({ public_key: deleteKey, }); @@ -162,128 +160,120 @@ describeIf("Database Services", () => { }); }); - describe("LoanHistoryService", () => { - it("should create a loan history record", async () => { + describe('LoanHistoryService', () => { + it('should create a loan history record', async () => { const loan = await LoanHistoryService.create({ loan_id: 99999, - borrower_public_key: "G_BORROWER_TEST", + borrower_public_key: 'G_BORROWER_TEST', principal_amount: 1000, interest_rate_bps: 1200, - status: "Pending", + status: 'Pending', }); expect(loan).toBeDefined(); expect(loan.loan_id).toBe(99999); - expect(loan.borrower_public_key).toBe("G_BORROWER_TEST"); - expect(loan.status).toBe("Pending"); + expect(loan.borrower_public_key).toBe('G_BORROWER_TEST'); + expect(loan.status).toBe('Pending'); - await query("DELETE FROM loan_history WHERE loan_id = $1", [99999]); + await query('DELETE FROM loan_history WHERE loan_id = $1', [99999]); }); - it("should find loans by borrower", async () => { + it('should find loans by borrower', async () => { await LoanHistoryService.create({ loan_id: 90001, - borrower_public_key: "G_BORROWER_FIND_TEST", + borrower_public_key: 'G_BORROWER_FIND_TEST', principal_amount: 1000, interest_rate_bps: 1200, - status: "Approved", + status: 'Approved', }); - const loans = await LoanHistoryService.findByBorrower( - "G_BORROWER_FIND_TEST", - ); + const loans = await LoanHistoryService.findByBorrower('G_BORROWER_FIND_TEST'); expect(loans.length).toBeGreaterThan(0); if (loans[0]) { - expect(loans[0].borrower_public_key).toBe("G_BORROWER_FIND_TEST"); + expect(loans[0].borrower_public_key).toBe('G_BORROWER_FIND_TEST'); } - await query("DELETE FROM loan_history WHERE loan_id = $1", [90001]); + await query('DELETE FROM loan_history WHERE loan_id = $1', [90001]); }); - it("should update loan status", async () => { + it('should update loan status', async () => { await LoanHistoryService.create({ loan_id: 90002, - borrower_public_key: "G_BORROWER_UPDATE_TEST", + borrower_public_key: 'G_BORROWER_UPDATE_TEST', principal_amount: 1000, interest_rate_bps: 1200, - status: "Approved", + status: 'Approved', }); const updated = await LoanHistoryService.update(90002, { - status: "Repaid", + status: 'Repaid', repaid_at: new Date(), }); expect(updated).toBeDefined(); - expect(updated?.status).toBe("Repaid"); + expect(updated?.status).toBe('Repaid'); - await query("DELETE FROM loan_history WHERE loan_id = $1", [90002]); + await query('DELETE FROM loan_history WHERE loan_id = $1', [90002]); }); }); - describe("IndexedEventsService", () => { - it("should create an indexed event", async () => { + describe('IndexedEventsService', () => { + it('should create an indexed event', async () => { const event = await IndexedEventsService.create({ - event_id: "event_test_001", - event_type: "LoanRequested", - contract_id: "CONTRACT_TEST", - tx_hash: "tx_hash_test", + event_id: 'event_test_001', + event_type: 'LoanRequested', + contract_id: 'CONTRACT_TEST', + tx_hash: 'tx_hash_test', ledger: 12345, ledger_closed_at: new Date(), }); expect(event).toBeDefined(); - expect(event.event_id).toBe("event_test_001"); + expect(event.event_id).toBe('event_test_001'); expect(event.processed).toBe(false); }); - it("should find unprocessed events", async () => { + it('should find unprocessed events', async () => { await IndexedEventsService.create({ - event_id: "event_unprocessed_001", - event_type: "LoanApproved", - contract_id: "CONTRACT_TEST", - tx_hash: "tx_hash_unprocessed", + event_id: 'event_unprocessed_001', + event_type: 'LoanApproved', + contract_id: 'CONTRACT_TEST', + tx_hash: 'tx_hash_unprocessed', ledger: 12346, ledger_closed_at: new Date(), }); const unprocessed = await IndexedEventsService.findUnprocessed(); expect(unprocessed.length).toBeGreaterThan(0); - expect( - unprocessed.some((e) => e.event_id === "event_unprocessed_001"), - ).toBe(true); + expect(unprocessed.some((e) => e.event_id === 'event_unprocessed_001')).toBe(true); }); - it("should mark event as processed", async () => { + it('should mark event as processed', async () => { await IndexedEventsService.create({ - event_id: "event_mark_processed", - event_type: "LoanRepaid", - contract_id: "CONTRACT_TEST", - tx_hash: "tx_hash_mark", + event_id: 'event_mark_processed', + event_type: 'LoanRepaid', + contract_id: 'CONTRACT_TEST', + tx_hash: 'tx_hash_mark', ledger: 12347, ledger_closed_at: new Date(), }); - const marked = await IndexedEventsService.markProcessed( - "event_mark_processed", - ); + const marked = await IndexedEventsService.markProcessed('event_mark_processed'); expect(marked).toBe(true); }); - it("should find events by transaction hash", async () => { - const events = await IndexedEventsService.findByTxHash("tx_hash_test"); + it('should find events by transaction hash', async () => { + const events = await IndexedEventsService.findByTxHash('tx_hash_test'); expect(events.length).toBeGreaterThan(0); }); afterAll(async () => { - await query("DELETE FROM indexed_events WHERE event_id LIKE $1", [ - "event_%", - ]); + await query('DELETE FROM indexed_events WHERE event_id LIKE $1', ['event_%']); }); }); - describe("DatabaseService", () => { - it("should perform health check", async () => { + describe('DatabaseService', () => { + it('should perform health check', async () => { const healthy = await DatabaseService.healthCheck(); expect(healthy).toBe(true); }); diff --git a/backend/src/__tests__/defaultChecker.test.ts b/backend/src/__tests__/defaultChecker.test.ts index 9c95644d..eded7f53 100644 --- a/backend/src/__tests__/defaultChecker.test.ts +++ b/backend/src/__tests__/defaultChecker.test.ts @@ -1,8 +1,8 @@ -import { jest } from "@jest/globals"; -import logger from "../utils/logger.js"; -import { DefaultChecker } from "../services/defaultChecker.js"; +import { jest } from '@jest/globals'; +import logger from '../utils/logger.js'; +import { DefaultChecker } from '../services/defaultChecker.js'; -describe("DefaultChecker", () => { +describe('DefaultChecker', () => { const originalBatchSize = process.env.DEFAULT_CHECK_BATCH_SIZE; const originalBatchTimeoutMs = process.env.DEFAULT_CHECK_BATCH_TIMEOUT_MS; @@ -22,54 +22,47 @@ describe("DefaultChecker", () => { } }); - it("times out a stuck batch and continues processing later batches", async () => { - process.env.DEFAULT_CHECK_BATCH_SIZE = "1"; - process.env.DEFAULT_CHECK_BATCH_TIMEOUT_MS = "10"; + it('times out a stuck batch and continues processing later batches', async () => { + process.env.DEFAULT_CHECK_BATCH_SIZE = '1'; + process.env.DEFAULT_CHECK_BATCH_TIMEOUT_MS = '10'; const checker = new DefaultChecker(); - const warnSpy = jest - .spyOn(logger, "warn") - .mockImplementation(() => logger as typeof logger); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger as typeof logger); - (checker as unknown as Record).acquireLock = async () => - true; - (checker as unknown as Record).releaseLock = async () => - undefined; + (checker as unknown as Record).acquireLock = async () => true; + (checker as unknown as Record).releaseLock = async () => undefined; (checker as unknown as Record).assertConfigured = () => ({ signer: {}, server: { getLatestLedger: async () => ({ sequence: 4321 }), }, - passphrase: "test-passphrase", + passphrase: 'test-passphrase', }); - (checker as unknown as Record).fetchOverdueStats = - async () => ({ - overdueCount: 2, - oldestDueLedger: 4200, - ledgersPastOldestDue: 121, - }); - (checker as unknown as Record).fetchOverdueLoanIds = - async () => [101, 102]; + (checker as unknown as Record).fetchOverdueStats = async () => ({ + overdueCount: 2, + oldestDueLedger: 4200, + ledgersPastOldestDue: 121, + }); + (checker as unknown as Record).fetchOverdueLoanIds = async () => [101, 102]; let submissionCount = 0; - (checker as unknown as Record).submitCheckDefaults = - async ( - _server: unknown, - _signer: unknown, - _passphrase: string, - loanIds: number[], - ) => { - submissionCount += 1; - if (submissionCount === 1) { - return new Promise(() => undefined); - } + (checker as unknown as Record).submitCheckDefaults = async ( + _server: unknown, + _signer: unknown, + _passphrase: string, + loanIds: number[], + ) => { + submissionCount += 1; + if (submissionCount === 1) { + return new Promise(() => undefined); + } - return { - loanIds, - txHash: "second-batch-hash", - submitStatus: "PENDING", - }; + return { + loanIds, + txHash: 'second-batch-hash', + submitStatus: 'PENDING', }; + }; const result = await checker.checkOverdueLoans(); @@ -77,15 +70,15 @@ describe("DefaultChecker", () => { expect(result!.batches[0]).toMatchObject({ loanIds: [101], timedOut: true, - error: "batch timed out after 10ms", + error: 'batch timed out after 10ms', }); expect(result!.batches[1]).toMatchObject({ loanIds: [102], - txHash: "second-batch-hash", - submitStatus: "PENDING", + txHash: 'second-batch-hash', + submitStatus: 'PENDING', }); expect(warnSpy).toHaveBeenCalledWith( - "Default check batch timed out", + 'Default check batch timed out', expect.objectContaining({ loanIds: [101], timeoutMs: 10, diff --git a/backend/src/__tests__/errorHandling.test.ts b/backend/src/__tests__/errorHandling.test.ts index 64bf6e4e..690b28af 100644 --- a/backend/src/__tests__/errorHandling.test.ts +++ b/backend/src/__tests__/errorHandling.test.ts @@ -12,9 +12,9 @@ const authHeader = `Bearer ${generateJwtToken(Keypair.random().publicKey())}`; describe("Centralized Error Handling", () => { /* ── 404 Not Found ────────────────────────────────────────── */ - describe("404 catch-all", () => { - it("should return 404 with structured JSON including error code", async () => { - const response = await request(app).get("/nonexistent-route"); + describe('404 catch-all', () => { + it('should return 404 with structured JSON including error code', async () => { + const response = await request(app).get('/nonexistent-route'); expect(response.status).toBe(404); expect(response.body.success).toBe(false); @@ -22,20 +22,16 @@ describe("Centralized Error Handling", () => { expect(response.body.message).toMatch(/Cannot GET \/nonexistent-route/); // New structured format expect(response.body.error).toBeDefined(); - expect(response.body.error.code).toBe("NOT_FOUND"); - expect(response.body.error.message).toMatch( - /Cannot GET \/nonexistent-route/, - ); + expect(response.body.error.code).toBe('NOT_FOUND'); + expect(response.body.error.message).toMatch(/Cannot GET \/nonexistent-route/); }); - it("should return 404 for unknown POST routes with error code", async () => { - const response = await request(app) - .post("/unknown") - .send({ data: "test" }); + it('should return 404 for unknown POST routes with error code', async () => { + const response = await request(app).post('/unknown').send({ data: 'test' }); expect(response.status).toBe(404); expect(response.body.success).toBe(false); - expect(response.body.error.code).toBe("NOT_FOUND"); + expect(response.body.error.code).toBe('NOT_FOUND'); }); }); @@ -51,12 +47,12 @@ describe("Centralized Error Handling", () => { expect(response.status).toBe(400); expect(response.body.success).toBe(false); // Legacy format - expect(response.body.message).toBe("Validation failed"); + expect(response.body.message).toBe('Validation failed'); expect(response.body.errors).toBeDefined(); // New structured format expect(response.body.error).toBeDefined(); - expect(response.body.error.code).toBe("VALIDATION_ERROR"); - expect(response.body.error.message).toBe("Validation failed"); + expect(response.body.error.code).toBe('VALIDATION_ERROR'); + expect(response.body.error.message).toBe('Validation failed'); expect(response.body.error.details).toBeDefined(); expect(Array.isArray(response.body.error.details)).toBe(true); }); @@ -70,67 +66,65 @@ describe("Centralized Error Handling", () => { expect(response.status).toBe(400); // Legacy format expect(response.body.errors.length).toBeGreaterThan(0); - expect(response.body.errors[0]).toHaveProperty("path"); - expect(response.body.errors[0]).toHaveProperty("message"); + expect(response.body.errors[0]).toHaveProperty('path'); + expect(response.body.errors[0]).toHaveProperty('message'); // New structured format expect(response.body.error.details.length).toBeGreaterThan(0); - expect(response.body.error.details[0]).toHaveProperty("field"); - expect(response.body.error.details[0]).toHaveProperty("message"); + expect(response.body.error.details[0]).toHaveProperty('field'); + expect(response.body.error.details[0]).toHaveProperty('message'); }); }); /* ── Consistent JSON structure ────────────────────────────── */ - describe("Response structure consistency", () => { - it("should always include success and error fields in error responses", async () => { - const response = await request(app).get("/does-not-exist"); + describe('Response structure consistency', () => { + it('should always include success and error fields in error responses', async () => { + const response = await request(app).get('/does-not-exist'); - expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty('success', false); // Legacy format - expect(response.body).toHaveProperty("message"); + expect(response.body).toHaveProperty('message'); // New structured format - expect(response.body).toHaveProperty("error"); - expect(response.body.error).toHaveProperty("code"); - expect(response.body.error).toHaveProperty("message"); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toHaveProperty('code'); + expect(response.body.error).toHaveProperty('message'); }); - it("should not expose stack traces in production-like responses", async () => { + it('should not expose stack traces in production-like responses', async () => { const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "production"; + process.env.NODE_ENV = 'production'; - const response = await request(app).get("/does-not-exist"); + const response = await request(app).get('/does-not-exist'); - expect(response.body.error).not.toHaveProperty("stack"); + expect(response.body.error).not.toHaveProperty('stack'); process.env.NODE_ENV = originalEnv; }); - it("should expose stack traces only with explicit development opt-in", async () => { + it('should expose stack traces only with explicit development opt-in', async () => { const originalEnv = process.env.NODE_ENV; const originalExposeStackTraces = process.env.EXPOSE_STACK_TRACES; - process.env.NODE_ENV = "development"; - process.env.EXPOSE_STACK_TRACES = "true"; - const developmentResponse = await request(app).get( - "/test/error/unexpected", - ); + process.env.NODE_ENV = 'development'; + process.env.EXPOSE_STACK_TRACES = 'true'; + const developmentResponse = await request(app).get('/test/error/unexpected'); expect(developmentResponse.status).toBe(500); - expect(developmentResponse.body).toHaveProperty("stack"); + expect(developmentResponse.body).toHaveProperty('stack'); - process.env.NODE_ENV = "development"; - process.env.EXPOSE_STACK_TRACES = "false"; - const noOptInResponse = await request(app).get("/test/error/unexpected"); + process.env.NODE_ENV = 'development'; + process.env.EXPOSE_STACK_TRACES = 'false'; + const noOptInResponse = await request(app).get('/test/error/unexpected'); expect(noOptInResponse.status).toBe(500); - expect(noOptInResponse.body).not.toHaveProperty("stack"); + expect(noOptInResponse.body).not.toHaveProperty('stack'); - process.env.NODE_ENV = "staging"; - process.env.EXPOSE_STACK_TRACES = "true"; - const stagingResponse = await request(app).get("/test/error/unexpected"); + process.env.NODE_ENV = 'staging'; + process.env.EXPOSE_STACK_TRACES = 'true'; + const stagingResponse = await request(app).get('/test/error/unexpected'); expect(stagingResponse.status).toBe(500); - expect(stagingResponse.body).not.toHaveProperty("stack"); + expect(stagingResponse.body).not.toHaveProperty('stack'); if (originalEnv === undefined) { delete process.env.NODE_ENV; @@ -148,88 +142,88 @@ describe("Centralized Error Handling", () => { /* ── Diagnostic Routes (Integration) ───────────────────────── */ - describe("Specific error scenarios (Diagnostic)", () => { - it("should handle operational AppErrors (400 Bad Request) with error code", async () => { - const response = await request(app).get("/test/error/operational"); + describe('Specific error scenarios (Diagnostic)', () => { + it('should handle operational AppErrors (400 Bad Request) with error code', async () => { + const response = await request(app).get('/test/error/operational'); expect(response.status).toBe(400); expect(response.body.success).toBe(false); // Legacy format - expect(response.body.message).toBe("Diagnostic operational error"); + expect(response.body.message).toBe('Diagnostic operational error'); // New structured format - expect(response.body.error.message).toBe("Diagnostic operational error"); + expect(response.body.error.message).toBe('Diagnostic operational error'); expect(response.body.error.code).toBeDefined(); }); - it("should handle internal AppErrors (500 Internal Server Error) with error code", async () => { - const response = await request(app).get("/test/error/internal"); + it('should handle internal AppErrors (500 Internal Server Error) with error code', async () => { + const response = await request(app).get('/test/error/internal'); expect(response.status).toBe(500); expect(response.body.success).toBe(false); // Legacy format - expect(response.body.message).toBe("Internal server error"); + expect(response.body.message).toBe('Internal server error'); // New structured format - expect(response.body.error.message).toBe("Internal server error"); - expect(response.body.error.code).toBe("INTERNAL_ERROR"); + expect(response.body.error.message).toBe('Internal server error'); + expect(response.body.error.code).toBe('INTERNAL_ERROR'); }); - it("should handle unexpected exceptions (500 Internal Server Error) with error code", async () => { - const response = await request(app).get("/test/error/unexpected"); + it('should handle unexpected exceptions (500 Internal Server Error) with error code', async () => { + const response = await request(app).get('/test/error/unexpected'); expect(response.status).toBe(500); expect(response.body.success).toBe(false); // Legacy format - expect(response.body.message).toBe("Internal server error"); + expect(response.body.message).toBe('Internal server error'); // New structured format - expect(response.body.error.message).toBe("Internal server error"); - expect(response.body.error.code).toBe("INTERNAL_ERROR"); + expect(response.body.error.message).toBe('Internal server error'); + expect(response.body.error.code).toBe('INTERNAL_ERROR'); }); - it("should catch async exceptions via asyncHandler middleware with error code", async () => { - const response = await request(app).get("/test/error/async"); + it('should catch async exceptions via asyncHandler middleware with error code', async () => { + const response = await request(app).get('/test/error/async'); expect(response.status).toBe(500); expect(response.body.success).toBe(false); // Legacy format - expect(response.body.message).toBe("Internal server error"); + expect(response.body.message).toBe('Internal server error'); // New structured format - expect(response.body.error.message).toBe("Internal server error"); - expect(response.body.error.code).toBe("INTERNAL_ERROR"); + expect(response.body.error.message).toBe('Internal server error'); + expect(response.body.error.code).toBe('INTERNAL_ERROR'); }); }); /* ── Authentication Error Codes ───────────────────────────── */ - describe("Authentication error codes", () => { - it("should return VALIDATION_ERROR error code for missing public key (Zod validation)", async () => { + describe('Authentication error codes', () => { + it('should return VALIDATION_ERROR error code for missing public key (Zod validation)', async () => { // Zod validation runs before controller logic - const response = await request(app).post("/api/auth/challenge").send({}); + const response = await request(app).post('/api/auth/challenge').send({}); expect(response.status).toBe(400); - expect(response.body.error.code).toBe("VALIDATION_ERROR"); - expect(response.body.error.details[0]?.field).toBe("publicKey"); + expect(response.body.error.code).toBe('VALIDATION_ERROR'); + expect(response.body.error.details[0]?.field).toBe('publicKey'); }); - it("should return INVALID_PUBLIC_KEY error code for invalid key format", async () => { + it('should return INVALID_PUBLIC_KEY error code for invalid key format', async () => { // Controller logic runs after Zod validation passes const response = await request(app) - .post("/api/auth/challenge") - .send({ publicKey: "invalid" }); + .post('/api/auth/challenge') + .send({ publicKey: 'invalid' }); expect(response.status).toBe(400); - expect(response.body.error.code).toBe("INVALID_PUBLIC_KEY"); - expect(response.body.error.field).toBe("publicKey"); + expect(response.body.error.code).toBe('INVALID_PUBLIC_KEY'); + expect(response.body.error.field).toBe('publicKey'); }); - it("should return VALIDATION_ERROR error code for missing signature in login (Zod validation)", async () => { + it('should return VALIDATION_ERROR error code for missing signature in login (Zod validation)', async () => { // Zod validation runs before controller logic const response = await request(app) - .post("/api/auth/login") - .send({ publicKey: "GXXX", message: "test" }); + .post('/api/auth/login') + .send({ publicKey: 'GXXX', message: 'test' }); expect(response.status).toBe(400); - expect(response.body.error.code).toBe("VALIDATION_ERROR"); - expect(response.body.error.details[0]?.field).toBe("signature"); + expect(response.body.error.code).toBe('VALIDATION_ERROR'); + expect(response.body.error.details[0]?.field).toBe('signature'); }); }); }); diff --git a/backend/src/__tests__/eventIndexer.test.ts b/backend/src/__tests__/eventIndexer.test.ts index b5229340..2abe4b1e 100644 --- a/backend/src/__tests__/eventIndexer.test.ts +++ b/backend/src/__tests__/eventIndexer.test.ts @@ -1,22 +1,13 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import { Address, Keypair, nativeToScVal } from "@stellar/stellar-sdk"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Address, Keypair, nativeToScVal } from '@stellar/stellar-sdk'; jest.setTimeout(30000); const mockQuery = - jest.fn< - ( - sql: string, - params?: unknown[], - ) => Promise<{ rows: unknown[]; rowCount: number }> - >(); -const mockDispatch = jest - .fn<() => Promise>() - .mockResolvedValue(undefined); + jest.fn<(sql: string, params?: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>(); +const mockDispatch = jest.fn<() => Promise>().mockResolvedValue(undefined); const mockBroadcast = jest.fn(); -const mockCreateNotification = jest - .fn<() => Promise>() - .mockResolvedValue(undefined); +const mockCreateNotification = jest.fn<() => Promise>().mockResolvedValue(undefined); const mockGetScoreConfig = jest.fn(() => ({ repaymentDelta: 15, defaultPenalty: 50, @@ -32,68 +23,61 @@ const mockLogger = { }; mockLogger.withContext.mockImplementation(() => mockLogger); const supportedWebhookEventTypes = [ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", - "CollateralLiquidated", - "LoanLiquidated", - "Deposit", - "Withdraw", - "YieldDistributed", - "EmergencyWithdraw", - "NFTMinted", - "ScoreUpdated", - "NFTSeized", - "NFTBurned", - "ProposalCreated", - "ProposalApproved", - "ProposalFinalized", - "Mint", - "ScoreUpd", - "Seized", - "GovProp", - "GovAppr", - "GovFin", - "Transfer", - "MntAuth", - "MntRev", - "Paused", - "Unpaused", - "MinScoreUpdated", - "InterestRateUpdated", - "DefaultTermUpdated", - "TermLimitsUpdated", - "LateFeeRateUpdated", - "GracePeriodUpdated", - "DefaultWindowUpdated", - "MaxLoanAmountUpdated", - "MinRepaymentUpdated", - "MaxLoansPerBorrower", - "MinRateBpsUpdated", - "MaxRateBpsUpdated", - "RateOracleUpdated", - "PoolPaused", - "PoolUnpaused", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', + 'CollateralLiquidated', + 'LoanLiquidated', + 'Deposit', + 'Withdraw', + 'YieldDistributed', + 'EmergencyWithdraw', + 'NFTMinted', + 'ScoreUpdated', + 'NFTSeized', + 'NFTBurned', + 'ProposalCreated', + 'ProposalApproved', + 'ProposalFinalized', + 'Mint', + 'ScoreUpd', + 'Seized', + 'GovProp', + 'GovAppr', + 'GovFin', + 'Transfer', + 'MntAuth', + 'MntRev', + 'Paused', + 'Unpaused', + 'MinScoreUpdated', + 'InterestRateUpdated', + 'DefaultTermUpdated', + 'TermLimitsUpdated', + 'LateFeeRateUpdated', + 'GracePeriodUpdated', + 'DefaultWindowUpdated', + 'MaxLoanAmountUpdated', + 'MinRepaymentUpdated', + 'MaxLoansPerBorrower', + 'MinRateBpsUpdated', + 'MaxRateBpsUpdated', + 'RateOracleUpdated', + 'PoolPaused', + 'PoolUnpaused', ] as const; -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: mockQuery, getClient: jest.fn(), closePool: jest.fn(), withTransaction: jest.fn( - async ( - fn: (client: { - query: typeof mockQuery; - release: () => void; - }) => Promise, - ) => { + async (fn: (client: { query: typeof mockQuery; release: () => void }) => Promise) => { // Provide a mock client whose .query() delegates to the shared mockQuery // so all existing SQL-inspection assertions in the tests keep working. const mockClient = { - query: jest.fn(async (sql: string, params?: unknown[]) => - mockQuery(sql, params ?? []), - ), + query: jest.fn(async (sql: string, params?: unknown[]) => mockQuery(sql, params ?? [])), release: jest.fn(), }; return fn(mockClient); @@ -101,59 +85,56 @@ jest.unstable_mockModule("../db/connection.js", () => ({ ), })); -jest.unstable_mockModule("../services/webhookService.js", () => ({ +jest.unstable_mockModule('../services/webhookService.js', () => ({ SUPPORTED_WEBHOOK_EVENT_TYPES: supportedWebhookEventTypes, webhookService: { dispatch: mockDispatch }, })); -jest.unstable_mockModule("../services/eventStreamService.js", () => ({ +jest.unstable_mockModule('../services/eventStreamService.js', () => ({ eventStreamService: { broadcast: mockBroadcast }, })); -jest.unstable_mockModule("../services/notificationService.js", () => ({ +jest.unstable_mockModule('../services/notificationService.js', () => ({ notificationService: { createNotification: mockCreateNotification }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { getScoreConfig: mockGetScoreConfig }, })); -jest.unstable_mockModule("../services/scoresService.js", () => ({ +jest.unstable_mockModule('../services/scoresService.js', () => ({ updateUserScoresBulk: mockUpdateUserScoresBulk, })); -jest.unstable_mockModule("../utils/logger.js", () => ({ +jest.unstable_mockModule('../utils/logger.js', () => ({ default: mockLogger, })); -jest.unstable_mockModule("../utils/requestContext.js", () => ({ - createRequestId: () => "test-request", - runWithRequestContext: async ( - _requestId: string, - callback: () => Promise, - ) => callback(), +jest.unstable_mockModule('../utils/requestContext.js', () => ({ + createRequestId: () => 'test-request', + runWithRequestContext: async (_requestId: string, callback: () => Promise) => callback(), })); -const { EventIndexer } = await import("../services/eventIndexer.js"); +const { EventIndexer } = await import('../services/eventIndexer.js'); function makeAddress() { return Keypair.random().publicKey(); } function scAddress(address: string) { - return nativeToScVal(Address.fromString(address), { type: "address" }); + return nativeToScVal(Address.fromString(address), { type: 'address' }); } function scI128(value: number) { - return nativeToScVal(BigInt(value), { type: "i128" }); + return nativeToScVal(BigInt(value), { type: 'i128' }); } function scU32(value: number) { - return nativeToScVal(value, { type: "u32" }); + return nativeToScVal(value, { type: 'u32' }); } function scSymbol(value: string) { - return nativeToScVal(value, { type: "symbol" }); + return nativeToScVal(value, { type: 'symbol' }); } function makeRawEvent(params: { @@ -169,46 +150,34 @@ function makeRawEvent(params: { id: params.id, pagingToken: `${params.ledger}`, ledger: params.ledger, - ledgerClosedAt: "2026-03-29T00:00:00.000Z", + ledgerClosedAt: '2026-03-29T00:00:00.000Z', txHash: `tx-${params.id}`, - contractId: "CINDEXERTEST", + contractId: 'CINDEXERTEST', }; switch (params.type) { - case "LoanRequested": + case 'LoanRequested': return { ...base, - topic: [ - scSymbol("LoanRequested"), - scU32(params.loanId ?? 1), - scAddress(borrower), - ], + topic: [scSymbol('LoanRequested'), scU32(params.loanId ?? 1), scAddress(borrower)], value: scI128(params.amount ?? 500), }; - case "LoanApproved": + case 'LoanApproved': return { ...base, - topic: [ - scSymbol("LoanApproved"), - scU32(params.loanId ?? 1), - scAddress(borrower), - ], + topic: [scSymbol('LoanApproved'), scU32(params.loanId ?? 1), scAddress(borrower)], value: nativeToScVal([1200, 17280]), }; - case "LoanRepaid": + case 'LoanRepaid': return { ...base, - topic: [ - scSymbol("LoanRepaid"), - scAddress(borrower), - scU32(params.loanId ?? 1), - ], + topic: [scSymbol('LoanRepaid'), scAddress(borrower), scU32(params.loanId ?? 1)], value: scI128(params.amount ?? 250), }; - case "LoanDefaulted": + case 'LoanDefaulted': return { ...base, - topic: [scSymbol("LoanDefaulted"), scU32(params.loanId ?? 1)], + topic: [scSymbol('LoanDefaulted'), scU32(params.loanId ?? 1)], value: scAddress(borrower), }; default: @@ -228,28 +197,24 @@ function makeAliasedEvent(params: { id: params.id, pagingToken: `${params.ledger}`, ledger: params.ledger, - ledgerClosedAt: "2026-03-29T00:00:00.000Z", + ledgerClosedAt: '2026-03-29T00:00:00.000Z', txHash: `tx-${params.id}`, - contractId: "CINDEXERTEST", + contractId: 'CINDEXERTEST', }; - if (params.rawType === "Deposit" || params.rawType === "EmergencyWithdraw") { + if (params.rawType === 'Deposit' || params.rawType === 'EmergencyWithdraw') { return { ...base, - topic: [ - scSymbol(params.rawType), - scAddress(borrower), - scAddress(makeAddress()), - ], + topic: [scSymbol(params.rawType), scAddress(borrower), scAddress(makeAddress())], value: nativeToScVal([BigInt(params.amount ?? 100), BigInt(1)]), }; } if ( - params.rawType === "Mint" || - params.rawType === "ScoreUpd" || - params.rawType === "NftBurned" || - params.rawType === "Seized" + params.rawType === 'Mint' || + params.rawType === 'ScoreUpd' || + params.rawType === 'NftBurned' || + params.rawType === 'Seized' ) { return { ...base, @@ -258,11 +223,7 @@ function makeAliasedEvent(params: { }; } - if ( - params.rawType === "GovProp" || - params.rawType === "GovAppr" || - params.rawType === "GovFin" - ) { + if (params.rawType === 'GovProp' || params.rawType === 'GovAppr' || params.rawType === 'GovFin') { return { ...base, topic: [scSymbol(params.rawType), scAddress(borrower)], @@ -277,19 +238,19 @@ function makeAdminConfigEvent(params: { id: string; ledger: number; eventType: - | "MinScoreUpdated" - | "InterestRateUpdated" - | "DefaultTermUpdated" - | "TermLimitsUpdated" - | "LateFeeRateUpdated" - | "GracePeriodUpdated" - | "DefaultWindowUpdated" - | "MaxLoanAmountUpdated" - | "MinRepaymentUpdated" - | "MaxLoansPerBorrower" - | "MinRateBpsUpdated" - | "MaxRateBpsUpdated" - | "RateOracleUpdated"; + | 'MinScoreUpdated' + | 'InterestRateUpdated' + | 'DefaultTermUpdated' + | 'TermLimitsUpdated' + | 'LateFeeRateUpdated' + | 'GracePeriodUpdated' + | 'DefaultWindowUpdated' + | 'MaxLoanAmountUpdated' + | 'MinRepaymentUpdated' + | 'MaxLoansPerBorrower' + | 'MinRateBpsUpdated' + | 'MaxRateBpsUpdated' + | 'RateOracleUpdated'; admin?: string; }) { const admin = params.admin ?? makeAddress(); @@ -297,27 +258,27 @@ function makeAdminConfigEvent(params: { id: params.id, pagingToken: `${params.ledger}`, ledger: params.ledger, - ledgerClosedAt: "2026-03-29T00:00:00.000Z", + ledgerClosedAt: '2026-03-29T00:00:00.000Z', txHash: `tx-${params.id}`, - contractId: "CINDEXERTEST", + contractId: 'CINDEXERTEST', }; const withAdminTopic = new Set([ - "LateFeeRateUpdated", - "GracePeriodUpdated", - "DefaultWindowUpdated", - "MaxLoanAmountUpdated", - "MinRepaymentUpdated", - "MaxLoansPerBorrower", - "MinRateBpsUpdated", - "MaxRateBpsUpdated", + 'LateFeeRateUpdated', + 'GracePeriodUpdated', + 'DefaultWindowUpdated', + 'MaxLoanAmountUpdated', + 'MinRepaymentUpdated', + 'MaxLoansPerBorrower', + 'MinRateBpsUpdated', + 'MaxRateBpsUpdated', ]); const topic = withAdminTopic.has(params.eventType) ? [scSymbol(params.eventType), scAddress(admin)] : [scSymbol(params.eventType)]; - if (params.eventType === "RateOracleUpdated") { + if (params.eventType === 'RateOracleUpdated') { return { ...base, topic, @@ -332,12 +293,12 @@ function makeAdminConfigEvent(params: { }; } -describe("EventIndexer", () => { +describe('EventIndexer', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("parses the four core loan event types and triggers downstream side effects", async () => { + it('parses the four core loan event types and triggers downstream side effects', async () => { const borrowerRequested = makeAddress(); const borrowerApproved = makeAddress(); const borrowerRepaid = makeAddress(); @@ -345,71 +306,67 @@ describe("EventIndexer", () => { const insertedLoanEvents: unknown[][] = []; const scoreUpdates: unknown[][] = []; - mockQuery.mockImplementation( - async (sql: string, params: unknown[] = []) => { - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql === 'BEGIN' || sql === 'COMMIT') { + return { rows: [], rowCount: 0 }; + } - if (sql.includes("INSERT INTO loan_events")) { - insertedLoanEvents.push(params); - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } + if (sql.includes('INSERT INTO loan_events')) { + insertedLoanEvents.push(params); + return { rows: [{ event_id: params[0] }], rowCount: 1 }; + } - if (sql.includes("INSERT INTO scores")) { - // Handle batched updates - params come as [user1, delta1, user2, delta2, ...] - for (let i = 0; i < params.length; i += 2) { - scoreUpdates.push([params[i], params[i + 1]]); - } - return { rows: [], rowCount: 1 }; + if (sql.includes('INSERT INTO scores')) { + // Handle batched updates - params come as [user1, delta1, user2, delta2, ...] + for (let i = 0; i < params.length; i += 2) { + scoreUpdates.push([params[i], params[i + 1]]); } + return { rows: [], rowCount: 1 }; + } - return { rows: [], rowCount: 0 }; - }, - ); + return { rows: [], rowCount: 0 }; + }); - mockUpdateUserScoresBulk.mockImplementation( - async (updates: Map) => { - for (const [userId, delta] of updates) { - scoreUpdates.push([userId, delta]); - } - }, - ); + mockUpdateUserScoresBulk.mockImplementation(async (updates: Map) => { + for (const [userId, delta] of updates) { + scoreUpdates.push([userId, delta]); + } + }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); (indexer as unknown as { rpc: { getEvents: unknown } }).rpc = { getEvents: async () => ({ events: [ makeRawEvent({ - id: "evt-requested", + id: 'evt-requested', ledger: 11, - type: "LoanRequested", + type: 'LoanRequested', borrower: borrowerRequested, amount: 800, }), makeRawEvent({ - id: "evt-approved", + id: 'evt-approved', ledger: 12, - type: "LoanApproved", + type: 'LoanApproved', borrower: borrowerApproved, loanId: 7, }), makeRawEvent({ - id: "evt-repaid", + id: 'evt-repaid', ledger: 13, - type: "LoanRepaid", + type: 'LoanRepaid', borrower: borrowerRepaid, loanId: 8, amount: 220, }), makeRawEvent({ - id: "evt-defaulted", + id: 'evt-defaulted', ledger: 14, - type: "LoanDefaulted", + type: 'LoanDefaulted', borrower: borrowerDefaulted, loanId: 9, }), @@ -422,19 +379,19 @@ describe("EventIndexer", () => { expect(lastProcessedLedger).toBe(14); expect(insertedLoanEvents).toHaveLength(4); expect(insertedLoanEvents.map((params) => params[1])).toEqual([ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', ]); expect(insertedLoanEvents[0]?.[3]).toBe(borrowerRequested); - expect(insertedLoanEvents[0]?.[4]).toBe("800"); + expect(insertedLoanEvents[0]?.[4]).toBe('800'); expect(insertedLoanEvents[1]?.[2]).toBe(7); expect(insertedLoanEvents[1]?.[3]).toBe(borrowerApproved); expect(insertedLoanEvents[1]?.[11]).toBe(1200); expect(insertedLoanEvents[1]?.[12]).toBe(17280); expect(insertedLoanEvents[2]?.[2]).toBe(8); - expect(insertedLoanEvents[2]?.[4]).toBe("220"); + expect(insertedLoanEvents[2]?.[4]).toBe('220'); expect(insertedLoanEvents[3]?.[2]).toBe(9); expect(insertedLoanEvents[3]?.[3]).toBe(borrowerDefaulted); @@ -451,91 +408,89 @@ describe("EventIndexer", () => { expect(mockCreateNotification).toHaveBeenCalledTimes(3); }); - it("normalizes pool, NFT, and governance events into indexable event types", async () => { + it('normalizes pool, NFT, and governance events into indexable event types', async () => { const depositor = makeAddress(); const emergencyWithdrawer = makeAddress(); const nftUser = makeAddress(); const governanceActor = makeAddress(); const insertedLoanEvents: unknown[][] = []; - mockQuery.mockImplementation( - async (sql: string, params: unknown[] = []) => { - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql === 'BEGIN' || sql === 'COMMIT') { + return { rows: [], rowCount: 0 }; + } - if (sql.includes("INSERT INTO loan_events")) { - insertedLoanEvents.push(params); - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } + if (sql.includes('INSERT INTO loan_events')) { + insertedLoanEvents.push(params); + return { rows: [{ event_id: params[0] }], rowCount: 1 }; + } - return { rows: [], rowCount: 0 }; - }, - ); + return { rows: [], rowCount: 0 }; + }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); (indexer as unknown as { rpc: { getEvents: unknown } }).rpc = { getEvents: async () => ({ events: [ makeAliasedEvent({ - id: "evt-deposit", + id: 'evt-deposit', ledger: 50, - rawType: "Deposit", + rawType: 'Deposit', borrower: depositor, amount: 700, }), makeAliasedEvent({ - id: "evt-emergency-withdraw", + id: 'evt-emergency-withdraw', ledger: 51, - rawType: "EmergencyWithdraw", + rawType: 'EmergencyWithdraw', borrower: emergencyWithdrawer, amount: 300, }), makeAliasedEvent({ - id: "evt-score", + id: 'evt-score', ledger: 52, - rawType: "ScoreUpd", + rawType: 'ScoreUpd', borrower: nftUser, amount: 640, }), makeAliasedEvent({ - id: "evt-seized", + id: 'evt-seized', ledger: 53, - rawType: "Seized", + rawType: 'Seized', borrower: nftUser, }), makeAliasedEvent({ - id: "evt-burned", + id: 'evt-burned', ledger: 54, - rawType: "NftBurned", + rawType: 'NftBurned', borrower: nftUser, }), makeAliasedEvent({ - id: "evt-gov-created", + id: 'evt-gov-created', ledger: 55, - rawType: "GovProp", + rawType: 'GovProp', borrower: governanceActor, }), makeAliasedEvent({ - id: "evt-gov-approved", + id: 'evt-gov-approved', ledger: 56, - rawType: "GovAppr", + rawType: 'GovAppr', borrower: governanceActor, }), makeAliasedEvent({ - id: "evt-gov-finalized", + id: 'evt-gov-finalized', ledger: 57, - rawType: "GovFin", + rawType: 'GovFin', borrower: governanceActor, }), makeAliasedEvent({ - id: "evt-minted", + id: 'evt-minted', ledger: 58, - rawType: "Mint", + rawType: 'Mint', borrower: nftUser, amount: 500, }), @@ -547,72 +502,70 @@ describe("EventIndexer", () => { expect(lastProcessedLedger).toBe(58); expect(insertedLoanEvents.map((params) => params[1])).toEqual([ - "Deposit", - "EmergencyWithdraw", - "ScoreUpdated", - "NFTSeized", - "NFTBurned", - "ProposalCreated", - "ProposalApproved", - "ProposalFinalized", - "NFTMinted", + 'Deposit', + 'EmergencyWithdraw', + 'ScoreUpdated', + 'NFTSeized', + 'NFTBurned', + 'ProposalCreated', + 'ProposalApproved', + 'ProposalFinalized', + 'NFTMinted', ]); expect(insertedLoanEvents[0]?.[3]).toBe(depositor); - expect(insertedLoanEvents[0]?.[4]).toBe("700"); + expect(insertedLoanEvents[0]?.[4]).toBe('700'); expect(insertedLoanEvents[1]?.[3]).toBe(emergencyWithdrawer); - expect(insertedLoanEvents[1]?.[4]).toBe("300"); + expect(insertedLoanEvents[1]?.[4]).toBe('300'); expect(insertedLoanEvents[2]?.[3]).toBe(nftUser); - expect(insertedLoanEvents[2]?.[4]).toBe("640"); + expect(insertedLoanEvents[2]?.[4]).toBe('640'); expect(insertedLoanEvents[5]?.[3]).toBe(governanceActor); expect(insertedLoanEvents[8]?.[3]).toBe(nftUser); - expect(insertedLoanEvents[8]?.[4]).toBe("500"); + expect(insertedLoanEvents[8]?.[4]).toBe('500'); expect(mockDispatch).toHaveBeenCalledTimes(9); expect(mockBroadcast).toHaveBeenCalledTimes(9); expect(mockCreateNotification).not.toHaveBeenCalled(); }); - it("deduplicates repeated events and only triggers side effects for inserted rows", async () => { + it('deduplicates repeated events and only triggers side effects for inserted rows', async () => { const borrower = makeAddress(); let insertCount = 0; const insertStatements: string[] = []; - mockQuery.mockImplementation( - async (sql: string, params: unknown[] = []) => { - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql === 'BEGIN' || sql === 'COMMIT') { + return { rows: [], rowCount: 0 }; + } - if (sql.includes("INSERT INTO loan_events")) { - insertStatements.push(sql); - insertCount += 1; - const inserted = insertCount === 1; - return { - rows: inserted ? [{ event_id: params[0] }] : [], - rowCount: inserted ? 1 : 0, - }; - } + if (sql.includes('INSERT INTO loan_events')) { + insertStatements.push(sql); + insertCount += 1; + const inserted = insertCount === 1; + return { + rows: inserted ? [{ event_id: params[0] }] : [], + rowCount: inserted ? 1 : 0, + }; + } - if (sql.includes("INSERT INTO scores")) { - return { rows: [], rowCount: 1 }; - } + if (sql.includes('INSERT INTO scores')) { + return { rows: [], rowCount: 1 }; + } - return { rows: [], rowCount: 0 }; - }, - ); + return { rows: [], rowCount: 0 }; + }); const duplicateEvent = makeRawEvent({ - id: "evt-duplicate", + id: 'evt-duplicate', ledger: 20, - type: "LoanRepaid", + type: 'LoanRepaid', borrower, loanId: 55, amount: 300, }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); (indexer as unknown as { rpc: { getEvents: unknown } }).rpc = { @@ -627,55 +580,53 @@ describe("EventIndexer", () => { expect(mockBroadcast).toHaveBeenCalledTimes(1); expect(mockCreateNotification).toHaveBeenCalledTimes(1); expect(mockGetScoreConfig).toHaveBeenCalledTimes(1); - expect(insertStatements[0]).toContain("ON CONFLICT DO NOTHING"); + expect(insertStatements[0]).toContain('ON CONFLICT DO NOTHING'); }); - it("ignores duplicate LoanApproved rows for the same loan and emits side effects once", async () => { + it('ignores duplicate LoanApproved rows for the same loan and emits side effects once', async () => { const borrower = makeAddress(); let approvedInsertCount = 0; - mockQuery.mockImplementation( - async (sql: string, params: unknown[] = []) => { - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql === 'BEGIN' || sql === 'COMMIT') { + return { rows: [], rowCount: 0 }; + } - if (sql.includes("INSERT INTO loan_events")) { - if (params[1] === "LoanApproved" && params[2] === 42) { - approvedInsertCount += 1; - const inserted = approvedInsertCount === 1; - return { - rows: inserted ? [{ event_id: params[0] }] : [], - rowCount: inserted ? 1 : 0, - }; - } - - return { rows: [{ event_id: params[0] }], rowCount: 1 }; + if (sql.includes('INSERT INTO loan_events')) { + if (params[1] === 'LoanApproved' && params[2] === 42) { + approvedInsertCount += 1; + const inserted = approvedInsertCount === 1; + return { + rows: inserted ? [{ event_id: params[0] }] : [], + rowCount: inserted ? 1 : 0, + }; } - return { rows: [], rowCount: 0 }; - }, - ); + return { rows: [{ event_id: params[0] }], rowCount: 1 }; + } + + return { rows: [], rowCount: 0 }; + }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); (indexer as unknown as { rpc: { getEvents: unknown } }).rpc = { getEvents: async () => ({ events: [ makeRawEvent({ - id: "evt-approved-001", + id: 'evt-approved-001', ledger: 31, - type: "LoanApproved", + type: 'LoanApproved', borrower, loanId: 42, }), makeRawEvent({ - id: "evt-approved-002", + id: 'evt-approved-002', ledger: 32, - type: "LoanApproved", + type: 'LoanApproved', borrower, loanId: 42, }), @@ -692,40 +643,38 @@ describe("EventIndexer", () => { expect(mockGetScoreConfig).not.toHaveBeenCalled(); }); - it("initializes missing indexer state and persists the last indexed ledger during polling", async () => { + it('initializes missing indexer state and persists the last indexed ledger during polling', async () => { const stateWrites: number[] = []; - mockQuery.mockImplementation( - async (sql: string, params: unknown[] = []) => { - if (sql.includes("SELECT last_indexed_ledger")) { - return { rows: [], rowCount: 0 }; - } + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql.includes('SELECT last_indexed_ledger')) { + return { rows: [], rowCount: 0 }; + } - if (sql.includes("INSERT INTO indexer_state")) { - stateWrites.push(Number(params[0] ?? 0)); - return { rows: [], rowCount: 1 }; - } + if (sql.includes('INSERT INTO indexer_state')) { + stateWrites.push(Number(params[0] ?? 0)); + return { rows: [], rowCount: 1 }; + } - if (sql.includes("UPDATE indexer_state")) { - stateWrites.push(Number(params[0])); - return { rows: [], rowCount: 1 }; - } + if (sql.includes('UPDATE indexer_state')) { + stateWrites.push(Number(params[0])); + return { rows: [], rowCount: 1 }; + } - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } + if (sql === 'BEGIN' || sql === 'COMMIT') { + return { rows: [], rowCount: 0 }; + } - if (sql.includes("INSERT INTO loan_events")) { - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } + if (sql.includes('INSERT INTO loan_events')) { + return { rows: [{ event_id: params[0] }], rowCount: 1 }; + } - return { rows: [], rowCount: 0 }; - }, - ); + return { rows: [], rowCount: 0 }; + }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); (indexer as unknown as { running: boolean }).running = true; @@ -739,9 +688,7 @@ describe("EventIndexer", () => { ).rpc = { getLatestLedger: async () => ({ sequence: 15 }), getEvents: async () => ({ - events: [ - makeRawEvent({ id: "evt-poll", ledger: 15, type: "LoanRequested" }), - ], + events: [makeRawEvent({ id: 'evt-poll', ledger: 15, type: 'LoanRequested' })], }), }; @@ -750,46 +697,42 @@ describe("EventIndexer", () => { expect(stateWrites).toEqual([0, 15]); }); - it("quarantines parse failures and emits growth alert logs", async () => { + it('quarantines parse failures and emits growth alert logs', async () => { const previousThreshold = process.env.QUARANTINE_ALERT_THRESHOLD; - process.env.QUARANTINE_ALERT_THRESHOLD = "2"; - - mockQuery.mockImplementation( - async (sql: string, _params: unknown[] = []) => { - if (sql.includes("INSERT INTO quarantine_events")) { - return { rows: [], rowCount: 1 }; - } + process.env.QUARANTINE_ALERT_THRESHOLD = '2'; - if ( - sql.includes("SELECT COUNT(*)::int AS count FROM quarantine_events") - ) { - return { rows: [{ count: 2 }], rowCount: 1 }; - } + mockQuery.mockImplementation(async (sql: string, _params: unknown[] = []) => { + if (sql.includes('INSERT INTO quarantine_events')) { + return { rows: [], rowCount: 1 }; + } - if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { - return { rows: [], rowCount: 0 }; - } + if (sql.includes('SELECT COUNT(*)::int AS count FROM quarantine_events')) { + return { rows: [{ count: 2 }], rowCount: 1 }; + } - if (sql.includes("INSERT INTO loan_events")) { - return { rows: [], rowCount: 0 }; - } + if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') { + return { rows: [], rowCount: 0 }; + } + if (sql.includes('INSERT INTO loan_events')) { return { rows: [], rowCount: 0 }; - }, - ); + } + + return { rows: [], rowCount: 0 }; + }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); const malformed = { ...makeRawEvent({ - id: "evt-malformed", + id: 'evt-malformed', ledger: 42, - type: "LoanRequested", + type: 'LoanRequested', }), - value: scSymbol("invalid-amount"), + value: scSymbol('invalid-amount'), }; (indexer as unknown as { rpc: { getEvents: unknown } }).rpc = { @@ -801,18 +744,16 @@ describe("EventIndexer", () => { await indexer.processEvents(42, 42); expect( - mockQuery.mock.calls.some(([sql]) => - String(sql).includes("INSERT INTO quarantine_events"), - ), + mockQuery.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO quarantine_events')), ).toBe(true); expect(mockLogger.warn).toHaveBeenCalledWith( - "Quarantine event count increased", + 'Quarantine event count increased', expect.objectContaining({ totalCount: 2, }), ); expect(mockLogger.error).toHaveBeenCalledWith( - "Quarantine event count exceeded alert threshold", + 'Quarantine event count exceeded alert threshold', expect.objectContaining({ threshold: 2, totalCount: 2, @@ -826,46 +767,44 @@ describe("EventIndexer", () => { } }); - it("parses and persists all admin config events", async () => { + it('parses and persists all admin config events', async () => { const insertedLoanEvents: unknown[][] = []; const insertedAuditRows: unknown[][] = []; - mockQuery.mockImplementation( - async (sql: string, params: unknown[] = []) => { - if (sql.includes("INSERT INTO loan_events")) { - insertedLoanEvents.push(params); - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql.includes('INSERT INTO loan_events')) { + insertedLoanEvents.push(params); + return { rows: [{ event_id: params[0] }], rowCount: 1 }; + } - if (sql.includes("INSERT INTO audit_logs")) { - insertedAuditRows.push(params); - return { rows: [], rowCount: 1 }; - } + if (sql.includes('INSERT INTO audit_logs')) { + insertedAuditRows.push(params); + return { rows: [], rowCount: 1 }; + } - return { rows: [], rowCount: 0 }; - }, - ); + return { rows: [], rowCount: 0 }; + }); const indexer = new EventIndexer({ - rpcUrl: "https://rpc.test", - contractId: "CINDEXERTEST", + rpcUrl: 'https://rpc.test', + contractId: 'CINDEXERTEST', }); const admin = makeAddress(); const adminEventTypes = [ - "MinScoreUpdated", - "InterestRateUpdated", - "DefaultTermUpdated", - "TermLimitsUpdated", - "LateFeeRateUpdated", - "GracePeriodUpdated", - "DefaultWindowUpdated", - "MaxLoanAmountUpdated", - "MinRepaymentUpdated", - "MaxLoansPerBorrower", - "MinRateBpsUpdated", - "MaxRateBpsUpdated", - "RateOracleUpdated", + 'MinScoreUpdated', + 'InterestRateUpdated', + 'DefaultTermUpdated', + 'TermLimitsUpdated', + 'LateFeeRateUpdated', + 'GracePeriodUpdated', + 'DefaultWindowUpdated', + 'MaxLoanAmountUpdated', + 'MinRepaymentUpdated', + 'MaxLoansPerBorrower', + 'MinRateBpsUpdated', + 'MaxRateBpsUpdated', + 'RateOracleUpdated', ] as const; (indexer as unknown as { rpc: { getEvents: unknown } }).rpc = { @@ -884,9 +823,7 @@ describe("EventIndexer", () => { await indexer.processEvents(200, 220); expect(insertedLoanEvents).toHaveLength(adminEventTypes.length); - expect(insertedLoanEvents.map((params) => params[1])).toEqual( - adminEventTypes, - ); + expect(insertedLoanEvents.map((params) => params[1])).toEqual(adminEventTypes); expect(insertedAuditRows).toHaveLength(adminEventTypes.length); }); }); diff --git a/backend/src/__tests__/eventStream.test.ts b/backend/src/__tests__/eventStream.test.ts index 2a7c7236..ca3f5cbb 100644 --- a/backend/src/__tests__/eventStream.test.ts +++ b/backend/src/__tests__/eventStream.test.ts @@ -1,18 +1,18 @@ -import request from "supertest"; -import { jest } from "@jest/globals"; -import { generateJwtToken } from "../services/authService.js"; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { generateJwtToken } from '../services/authService.js'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -const VALID_API_KEY = "test-internal-key"; +const VALID_API_KEY = 'test-internal-key'; -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; process.env.INTERNAL_API_KEY = VALID_API_KEY; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -20,10 +20,9 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -await import("../db/connection.js"); -const { default: app } = await import("../app.js"); -const { eventStreamService } = - await import("../services/eventStreamService.js"); +await import('../db/connection.js'); +const { default: app } = await import('../app.js'); +const { eventStreamService } = await import('../services/eventStreamService.js'); const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, @@ -42,25 +41,23 @@ afterAll(() => { // --------------------------------------------------------------------------- // GET /api/events/stream // --------------------------------------------------------------------------- -describe("GET /api/events/stream", () => { - it("should reject unauthenticated SSE requests", async () => { - const response = await request(app).get("/api/events/stream"); +describe('GET /api/events/stream', () => { + it('should reject unauthenticated SSE requests', async () => { + const response = await request(app).get('/api/events/stream'); expect(response.status).toBe(401); }); - it("should reject token passed in query string", async () => { - const token = generateJwtToken("GQUERYTOKENUSER"); - const response = await request(app).get( - `/api/events/stream?token=${token}`, - ); + it('should reject token passed in query string', async () => { + const token = generateJwtToken('GQUERYTOKENUSER'); + const response = await request(app).get(`/api/events/stream?token=${token}`); expect(response.status).toBe(401); }); - it("should reject borrower stream access for a different wallet", async () => { + it('should reject borrower stream access for a different wallet', async () => { const response = await request(app) - .get("/api/events/stream?borrower=GOTHERWALLET") - .set(bearer("GOWNERWALLET")); + .get('/api/events/stream?borrower=GOTHERWALLET') + .set(bearer('GOWNERWALLET')); expect(response.status).toBe(403); }); @@ -69,47 +66,41 @@ describe("GET /api/events/stream", () => { // --------------------------------------------------------------------------- // GET /api/events/status // --------------------------------------------------------------------------- -describe("GET /api/events/status", () => { - it("should reject requests without API key", async () => { - const response = await request(app).get("/api/events/status"); +describe('GET /api/events/status', () => { + it('should reject requests without API key', async () => { + const response = await request(app).get('/api/events/status'); expect(response.status).toBe(401); }); - it("should return connection counts with valid API key", async () => { - const response = await request(app) - .get("/api/events/status") - .set("x-api-key", VALID_API_KEY); + it('should return connection counts with valid API key', async () => { + const response = await request(app).get('/api/events/status').set('x-api-key', VALID_API_KEY); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); - expect(typeof response.body.data.total).toBe("number"); - expect(typeof response.body.data.borrower).toBe("number"); - expect(typeof response.body.data.admin).toBe("number"); + expect(typeof response.body.data.total).toBe('number'); + expect(typeof response.body.data.borrower).toBe('number'); + expect(typeof response.body.data.admin).toBe('number'); }); }); // --------------------------------------------------------------------------- // EventStreamService unit tests // --------------------------------------------------------------------------- -describe("EventStreamService", () => { - it("should track connection counts", () => { +describe('EventStreamService', () => { + it('should track connection counts', () => { const counts = eventStreamService.getConnectionCount(); expect(counts.total).toBeGreaterThanOrEqual(0); expect(counts.borrower).toBeGreaterThanOrEqual(0); expect(counts.admin).toBeGreaterThanOrEqual(0); }); - it("should subscribe and unsubscribe borrower clients", () => { + it('should subscribe and unsubscribe borrower clients', () => { const mockRes = { write: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; - const unsubscribe = eventStreamService.subscribeAddress( - "testUser", - "testUser", - mockRes, - ); + const unsubscribe = eventStreamService.subscribeAddress('testUser', 'testUser', mockRes); const counts = eventStreamService.getConnectionCount(); expect(counts.borrower).toBeGreaterThanOrEqual(1); @@ -118,96 +109,84 @@ describe("EventStreamService", () => { expect(countsAfter.borrower).toBeLessThan(counts.borrower + 1); }); - it("should subscribe and unsubscribe admin clients", () => { + it('should subscribe and unsubscribe admin clients', () => { const mockRes = { write: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; - const unsubscribe = eventStreamService.subscribeAll("adminUser", mockRes); + const unsubscribe = eventStreamService.subscribeAll('adminUser', mockRes); const counts = eventStreamService.getConnectionCount(); expect(counts.admin).toBeGreaterThanOrEqual(1); unsubscribe(); }); - it("should broadcast events to borrower clients", () => { + it('should broadcast events to borrower clients', () => { const mockRes = { write: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; - const unsubscribe = eventStreamService.subscribeAddress( - "BORROWER1", - "BORROWER1", - mockRes, - ); + const unsubscribe = eventStreamService.subscribeAddress('BORROWER1', 'BORROWER1', mockRes); eventStreamService.broadcast({ - eventId: "evt-1", - eventType: "LoanRepaid", - address: "BORROWER1", + eventId: 'evt-1', + eventType: 'LoanRepaid', + address: 'BORROWER1', ledger: 1000, - ledgerClosedAt: "2026-03-01T00:00:00Z", - txHash: "abc123", + ledgerClosedAt: '2026-03-01T00:00:00Z', + txHash: 'abc123', }); expect(mockRes.write).toHaveBeenCalledTimes(1); - const writtenData = (mockRes.write as jest.Mock).mock.calls[0]?.[0] as - | string - | undefined; + const writtenData = (mockRes.write as jest.Mock).mock.calls[0]?.[0] as string | undefined; expect(writtenData).toBeDefined(); - expect(writtenData).toContain("id: evt-1"); - expect(writtenData).toContain("event: loan-event"); - expect(writtenData).toContain("LoanRepaid"); + expect(writtenData).toContain('id: evt-1'); + expect(writtenData).toContain('event: loan-event'); + expect(writtenData).toContain('LoanRepaid'); unsubscribe(); }); - it("should broadcast events to admin clients", () => { + it('should broadcast events to admin clients', () => { const mockRes = { write: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; - const unsubscribe = eventStreamService.subscribeAll("adminUser", mockRes); + const unsubscribe = eventStreamService.subscribeAll('adminUser', mockRes); eventStreamService.broadcast({ - eventId: "evt-2", - eventType: "LoanApproved", - address: "SOMEONE", + eventId: 'evt-2', + eventType: 'LoanApproved', + address: 'SOMEONE', ledger: 2000, - ledgerClosedAt: "2026-03-02T00:00:00Z", - txHash: "def456", + ledgerClosedAt: '2026-03-02T00:00:00Z', + txHash: 'def456', }); expect(mockRes.write).toHaveBeenCalledTimes(1); - const writtenData = (mockRes.write as jest.Mock).mock.calls[0]?.[0] as - | string - | undefined; + const writtenData = (mockRes.write as jest.Mock).mock.calls[0]?.[0] as string | undefined; expect(writtenData).toBeDefined(); - expect(writtenData).toContain("id: evt-2"); - expect(writtenData).toContain("event: loan-event"); + expect(writtenData).toContain('id: evt-2'); + expect(writtenData).toContain('event: loan-event'); unsubscribe(); }); - it("should not broadcast to unrelated borrower clients", () => { + it('should not broadcast to unrelated borrower clients', () => { const mockRes = { write: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; - const unsubscribe = eventStreamService.subscribeAddress( - "BORROWER_A", - "BORROWER_A", - mockRes, - ); + const unsubscribe = eventStreamService.subscribeAddress('BORROWER_A', 'BORROWER_A', mockRes); eventStreamService.broadcast({ - eventId: "evt-3", - eventType: "LoanRepaid", - address: "BORROWER_B", + eventId: 'evt-3', + eventType: 'LoanRepaid', + address: 'BORROWER_B', ledger: 3000, - ledgerClosedAt: "2026-03-03T00:00:00Z", - txHash: "ghi789", + ledgerClosedAt: '2026-03-03T00:00:00Z', + txHash: 'ghi789', }); expect(mockRes.write).not.toHaveBeenCalled(); @@ -215,87 +194,65 @@ describe("EventStreamService", () => { unsubscribe(); }); - it("should enforce a maximum of three connections per user", () => { + it('should enforce a maximum of three connections per user', () => { const createMockResponse = () => ({ write: jest.fn(), - }) as unknown as import("express").Response; + }) as unknown as import('express').Response; - expect(eventStreamService.canOpenConnection("BORROWER_LIMIT")).toBe(true); + expect(eventStreamService.canOpenConnection('BORROWER_LIMIT')).toBe(true); const unsubscribers = [ - eventStreamService.subscribeAddress( - "BORROWER_LIMIT", - "BORROWER_LIMIT", - createMockResponse(), - ), - eventStreamService.subscribeAddress( - "BORROWER_LIMIT", - "BORROWER_LIMIT", - createMockResponse(), - ), - eventStreamService.subscribeAddress( - "BORROWER_LIMIT", - "BORROWER_LIMIT", - createMockResponse(), - ), + eventStreamService.subscribeAddress('BORROWER_LIMIT', 'BORROWER_LIMIT', createMockResponse()), + eventStreamService.subscribeAddress('BORROWER_LIMIT', 'BORROWER_LIMIT', createMockResponse()), + eventStreamService.subscribeAddress('BORROWER_LIMIT', 'BORROWER_LIMIT', createMockResponse()), ]; - expect(eventStreamService.getUserConnectionCount("BORROWER_LIMIT")).toBe(3); - expect(eventStreamService.canOpenConnection("BORROWER_LIMIT")).toBe(false); + expect(eventStreamService.getUserConnectionCount('BORROWER_LIMIT')).toBe(3); + expect(eventStreamService.canOpenConnection('BORROWER_LIMIT')).toBe(false); unsubscribers.forEach((unsubscribe) => unsubscribe()); }); - it("should close active SSE connections with a shutdown event", () => { + it('should close active SSE connections with a shutdown event', () => { const borrowerRes = { write: jest.fn(), end: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; const adminRes = { write: jest.fn(), end: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; - eventStreamService.subscribeAddress("BORROWER1", "BORROWER1", borrowerRes); - eventStreamService.subscribeAll("ADMIN1", adminRes); + eventStreamService.subscribeAddress('BORROWER1', 'BORROWER1', borrowerRes); + eventStreamService.subscribeAll('ADMIN1', adminRes); - eventStreamService.closeAllConnections("Server shutting down"); + eventStreamService.closeAllConnections('Server shutting down'); - expect(borrowerRes.write).toHaveBeenCalledWith( - expect.stringContaining("event: shutdown"), - ); - expect(adminRes.write).toHaveBeenCalledWith( - expect.stringContaining('"type":"shutdown"'), - ); + expect(borrowerRes.write).toHaveBeenCalledWith(expect.stringContaining('event: shutdown')); + expect(adminRes.write).toHaveBeenCalledWith(expect.stringContaining('"type":"shutdown"')); expect(borrowerRes.end).toHaveBeenCalledTimes(1); expect(adminRes.end).toHaveBeenCalledTimes(1); expect(eventStreamService.getConnectionCount().total).toBe(0); }); - it("should emit replay-compatible SSE event payload from sendEvent", () => { + it('should emit replay-compatible SSE event payload from sendEvent', () => { const mockRes = { write: jest.fn(), - } as unknown as import("express").Response; + } as unknown as import('express').Response; eventStreamService.sendEvent(mockRes, { - eventId: "evt-99", - eventType: "LoanRequested", - address: "GBORROWER", + eventId: 'evt-99', + eventType: 'LoanRequested', + address: 'GBORROWER', ledger: 999, - ledgerClosedAt: "2026-03-09T00:00:00Z", - txHash: "xyz999", + ledgerClosedAt: '2026-03-09T00:00:00Z', + txHash: 'xyz999', }); expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith( - expect.stringContaining("id: evt-99"), - ); - expect(mockRes.write).toHaveBeenCalledWith( - expect.stringContaining("event: loan-event"), - ); - expect(mockRes.write).toHaveBeenCalledWith( - expect.stringContaining("LoanRequested"), - ); + expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('id: evt-99')); + expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('event: loan-event')); + expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('LoanRequested')); }); }); diff --git a/backend/src/__tests__/gracefulShutdown.test.ts b/backend/src/__tests__/gracefulShutdown.test.ts index de23a9b6..4d76847e 100644 --- a/backend/src/__tests__/gracefulShutdown.test.ts +++ b/backend/src/__tests__/gracefulShutdown.test.ts @@ -1,5 +1,5 @@ -import { jest } from "@jest/globals"; -import type { Pool } from "pg"; +import { jest } from '@jest/globals'; +import type { Pool } from 'pg'; type CloseCb = (err?: Error) => void; @@ -7,10 +7,10 @@ type MockServer = { close: (cb?: CloseCb) => void; }; -describe("Graceful Shutdown", () => { +describe('Graceful Shutdown', () => { let mockServer: MockServer; let mockPool: Partial; - let shutdownHandler: (signal: "SIGTERM" | "SIGINT") => Promise; + let shutdownHandler: (signal: 'SIGTERM' | 'SIGINT') => Promise; beforeEach(() => { jest.clearAllMocks(); @@ -26,13 +26,13 @@ describe("Graceful Shutdown", () => { }; }); - it("should close server on SIGTERM", async () => { + it('should close server on SIGTERM', async () => { const closeSpy = jest.fn((callback?: CloseCb) => { if (callback) callback(); }); mockServer.close = closeSpy; - shutdownHandler = async (_signal: "SIGTERM" | "SIGINT") => { + shutdownHandler = async (_signal: 'SIGTERM' | 'SIGINT') => { return new Promise((resolve) => { mockServer.close((err?: Error) => { if (err) throw err; @@ -41,17 +41,17 @@ describe("Graceful Shutdown", () => { }); }; - await shutdownHandler("SIGTERM"); + await shutdownHandler('SIGTERM'); expect(closeSpy).toHaveBeenCalledTimes(1); }); - it("should close server on SIGINT", async () => { + it('should close server on SIGINT', async () => { const closeSpy = jest.fn((callback?: CloseCb) => { if (callback) callback(); }); mockServer.close = closeSpy; - shutdownHandler = async (_signal: "SIGTERM" | "SIGINT") => { + shutdownHandler = async (_signal: 'SIGTERM' | 'SIGINT') => { return new Promise((resolve) => { mockServer.close((err?: Error) => { if (err) throw err; @@ -60,11 +60,11 @@ describe("Graceful Shutdown", () => { }); }; - await shutdownHandler("SIGINT"); + await shutdownHandler('SIGINT'); expect(closeSpy).toHaveBeenCalledTimes(1); }); - it("should drain database pool after server closes", async () => { + it('should drain database pool after server closes', async () => { const closeSpy = jest.fn((callback?: CloseCb) => { if (callback) callback(); }); @@ -72,7 +72,7 @@ describe("Graceful Shutdown", () => { mockServer.close = closeSpy; mockPool.end = endSpy; - shutdownHandler = async (_signal: "SIGTERM" | "SIGINT") => { + shutdownHandler = async (_signal: 'SIGTERM' | 'SIGINT') => { return new Promise((resolve) => { mockServer.close(async (err?: Error) => { if (err) throw err; @@ -82,12 +82,12 @@ describe("Graceful Shutdown", () => { }); }; - await shutdownHandler("SIGTERM"); + await shutdownHandler('SIGTERM'); expect(closeSpy).toHaveBeenCalledTimes(1); expect(endSpy).toHaveBeenCalledTimes(1); }); - it("should timeout after 30 seconds if shutdown stalls", () => { + it('should timeout after 30 seconds if shutdown stalls', () => { jest.useFakeTimers(); const closeSpy = jest.fn((_callback?: CloseCb) => { @@ -95,7 +95,7 @@ describe("Graceful Shutdown", () => { }); mockServer.close = closeSpy; - const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { // Mock implementation that doesn't actually exit }) as (code?: number | string | null | undefined) => never); @@ -113,7 +113,7 @@ describe("Graceful Shutdown", () => { jest.useRealTimers(); }); - it("should complete graceful shutdown within timeout", () => { + it('should complete graceful shutdown within timeout', () => { jest.useFakeTimers(); const closeSpy = jest.fn((callback?: CloseCb) => { @@ -122,7 +122,7 @@ describe("Graceful Shutdown", () => { }); mockServer.close = closeSpy; - const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { // Mock implementation that doesn't actually exit }) as (code?: number | string | null | undefined) => never); @@ -145,18 +145,18 @@ describe("Graceful Shutdown", () => { jest.useRealTimers(); }); - it("should handle server close errors gracefully", async () => { - const testError = new Error("Server close failed"); + it('should handle server close errors gracefully', async () => { + const testError = new Error('Server close failed'); const closeSpy = jest.fn((callback?: CloseCb) => { if (callback) callback(testError); }); mockServer.close = closeSpy; - const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); }); - shutdownHandler = async (_signal: "SIGTERM" | "SIGINT") => { + shutdownHandler = async (_signal: 'SIGTERM' | 'SIGINT') => { return new Promise((resolve, reject) => { mockServer.close((err?: Error) => { if (err) { @@ -169,9 +169,7 @@ describe("Graceful Shutdown", () => { }); }; - await expect(shutdownHandler("SIGTERM")).rejects.toThrow( - "process.exit called", - ); + await expect(shutdownHandler('SIGTERM')).rejects.toThrow('process.exit called'); expect(exitSpy).toHaveBeenCalledWith(1); exitSpy.mockRestore(); diff --git a/backend/src/__tests__/health.test.ts b/backend/src/__tests__/health.test.ts index eadddd1d..455fcfd6 100644 --- a/backend/src/__tests__/health.test.ts +++ b/backend/src/__tests__/health.test.ts @@ -1,8 +1,8 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import request from 'supertest'; // Use unstable_mockModule for robust ESM mocking -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: jest .fn<() => Promise<{ rows: unknown[]; rowCount: number }>>() @@ -15,54 +15,54 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); // Use dynamic import for app to ensure mocks are applied -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); -describe("GET /health", () => { - it("should return 200 or 503 with a status field", async () => { - const response = await request(app).get("/health"); +describe('GET /health', () => { + it('should return 200 or 503 with a status field', async () => { + const response = await request(app).get('/health'); expect([200, 503]).toContain(response.status); - expect(["ok", "degraded"]).toContain(response.body.status); + expect(['ok', 'degraded']).toContain(response.body.status); }); - it("should always report api check as ok", async () => { - const response = await request(app).get("/health"); + it('should always report api check as ok', async () => { + const response = await request(app).get('/health'); - expect(response.body).toHaveProperty("checks"); - expect(response.body.checks.api).toBe("ok"); + expect(response.body).toHaveProperty('checks'); + expect(response.body.checks.api).toBe('ok'); }); - it("should include soroban_rpc in checks", async () => { - const response = await request(app).get("/health"); + it('should include soroban_rpc in checks', async () => { + const response = await request(app).get('/health'); - expect(response.body.checks).toHaveProperty("soroban_rpc"); - expect(["ok", "error"]).toContain(response.body.checks.soroban_rpc); + expect(response.body.checks).toHaveProperty('soroban_rpc'); + expect(['ok', 'error']).toContain(response.body.checks.soroban_rpc); }); - it("should return uptime as a number", async () => { - const response = await request(app).get("/health"); + it('should return uptime as a number', async () => { + const response = await request(app).get('/health'); - expect(response.body).toHaveProperty("uptime"); - expect(typeof response.body.uptime).toBe("number"); + expect(response.body).toHaveProperty('uptime'); + expect(typeof response.body.uptime).toBe('number'); }); - it("should return timestamp as a number", async () => { - const response = await request(app).get("/health"); + it('should return timestamp as a number', async () => { + const response = await request(app).get('/health'); - expect(response.body).toHaveProperty("timestamp"); - expect(typeof response.body.timestamp).toBe("number"); + expect(response.body).toHaveProperty('timestamp'); + expect(typeof response.body.timestamp).toBe('number'); }); }); diff --git a/backend/src/__tests__/healthDeep.test.ts b/backend/src/__tests__/healthDeep.test.ts index f71b77c7..c595c9fd 100644 --- a/backend/src/__tests__/healthDeep.test.ts +++ b/backend/src/__tests__/healthDeep.test.ts @@ -1,5 +1,5 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import request from "supertest"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import request from 'supertest'; /** * Tests for GET /health/deep @@ -7,23 +7,19 @@ import request from "supertest"; */ const mockDbQuery = jest - .fn< - ( - sql?: unknown, - ) => Promise<{ rows: Record[]; rowCount: number }> - >() + .fn<(sql?: unknown) => Promise<{ rows: Record[]; rowCount: number }>>() .mockResolvedValue({ rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockDbQuery }, query: mockDbQuery, getClient: jest.fn(), withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), @@ -37,22 +33,22 @@ const mockHealthCheck = jest latestLedger: 1050, }); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), healthCheck: mockHealthCheck, }, })); -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); -describe("GET /health/deep", () => { +describe('GET /health/deep', () => { beforeEach(() => { mockDbQuery.mockReset(); mockHealthCheck.mockReset(); }); - describe("all healthy", () => { + describe('all healthy', () => { beforeEach(() => { // RPC returns ledger 1050; indexer is at 1000 → lag = 50 (below default threshold of 100) mockHealthCheck.mockResolvedValue({ @@ -60,102 +56,102 @@ describe("GET /health/deep", () => { latestLedger: 1050, }); mockDbQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } - return { rows: [{ "?column?": 1 }], rowCount: 1 }; + return { rows: [{ '?column?': 1 }], rowCount: 1 }; }); }); - it("returns HTTP 200", async () => { - const res = await request(app).get("/health/deep"); + it('returns HTTP 200', async () => { + const res = await request(app).get('/health/deep'); expect(res.status).toBe(200); }); - it("returns overall status ok", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.status).toBe("ok"); + it('returns overall status ok', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.status).toBe('ok'); }); - it("includes all four check keys", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.checks).toHaveProperty("db"); - expect(res.body.checks).toHaveProperty("redis"); - expect(res.body.checks).toHaveProperty("stellarRpc"); - expect(res.body.checks).toHaveProperty("indexer"); - expect(res.body.checks.indexer).toHaveProperty("lagLedgers"); + it('includes all four check keys', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.checks).toHaveProperty('db'); + expect(res.body.checks).toHaveProperty('redis'); + expect(res.body.checks).toHaveProperty('stellarRpc'); + expect(res.body.checks).toHaveProperty('indexer'); + expect(res.body.checks.indexer).toHaveProperty('lagLedgers'); }); - it("reports db, redis, stellarRpc as ok", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.checks.db).toBe("ok"); - expect(res.body.checks.redis).toBe("ok"); - expect(res.body.checks.stellarRpc).toBe("ok"); + it('reports db, redis, stellarRpc as ok', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.checks.db).toBe('ok'); + expect(res.body.checks.redis).toBe('ok'); + expect(res.body.checks.stellarRpc).toBe('ok'); }); - it("reports indexer status ok when lag is within threshold", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.checks.indexer.status).toBe("ok"); + it('reports indexer status ok when lag is within threshold', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.checks.indexer.status).toBe('ok'); expect(res.body.checks.indexer.lagLedgers).toBe(50); }); }); - describe("degraded – indexer lag exceeds threshold", () => { + describe('degraded – indexer lag exceeds threshold', () => { beforeEach(() => { - process.env.INDEXER_HEALTH_LAG_LIMIT = "30"; + process.env.INDEXER_HEALTH_LAG_LIMIT = '30'; // RPC is at 1100; indexer at 1000 → lag = 100 which exceeds 30 mockHealthCheck.mockResolvedValue({ connected: true, latestLedger: 1100, }); mockDbQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } - return { rows: [{ "?column?": 1 }], rowCount: 1 }; + return { rows: [{ '?column?': 1 }], rowCount: 1 }; }); }); - it("returns HTTP 200 (not 503) when only indexer is degraded", async () => { - const res = await request(app).get("/health/deep"); + it('returns HTTP 200 (not 503) when only indexer is degraded', async () => { + const res = await request(app).get('/health/deep'); expect(res.status).toBe(200); }); - it("returns overall status degraded", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.status).toBe("degraded"); + it('returns overall status degraded', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.status).toBe('degraded'); }); - it("reports indexer status as degraded", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.checks.indexer.status).toBe("degraded"); + it('reports indexer status as degraded', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.checks.indexer.status).toBe('degraded'); }); }); - describe("RPC down → 503", () => { + describe('RPC down → 503', () => { beforeEach(() => { mockHealthCheck.mockResolvedValue({ connected: false }); mockDbQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } - return { rows: [{ "?column?": 1 }], rowCount: 1 }; + return { rows: [{ '?column?': 1 }], rowCount: 1 }; }); }); - it("returns HTTP 503 when stellarRpc is down", async () => { - const res = await request(app).get("/health/deep"); + it('returns HTTP 503 when stellarRpc is down', async () => { + const res = await request(app).get('/health/deep'); expect(res.status).toBe(503); }); - it("returns overall status down", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.status).toBe("down"); + it('returns overall status down', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.status).toBe('down'); }); - it("reports stellarRpc as down", async () => { - const res = await request(app).get("/health/deep"); - expect(res.body.checks.stellarRpc).toBe("down"); + it('reports stellarRpc as down', async () => { + const res = await request(app).get('/health/deep'); + expect(res.body.checks.stellarRpc).toBe('down'); }); }); }); diff --git a/backend/src/__tests__/indexerRouteScopes.test.ts b/backend/src/__tests__/indexerRouteScopes.test.ts index f9c4e1dd..7c5c5962 100644 --- a/backend/src/__tests__/indexerRouteScopes.test.ts +++ b/backend/src/__tests__/indexerRouteScopes.test.ts @@ -1,19 +1,15 @@ -import express, { type Express, type Request, type Response } from "express"; -import request from "supertest"; -import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; +import express, { type Express, type Request, type Response } from 'express'; +import request from 'supertest'; +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; -const mockCreateWebhookSubscription = jest.fn( - (_req: Request, res: Response) => { - res.status(201).json({ success: true }); - }, -); -const mockQuery = jest.fn< - (...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }> ->(); +const mockCreateWebhookSubscription = jest.fn((_req: Request, res: Response) => { + res.status(201).json({ success: true }); +}); +const mockQuery = jest.fn<(...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>(); const okHandler = (_req: Request, res: Response) => res.json({ success: true }); -jest.unstable_mockModule("../controllers/indexerController.js", () => ({ +jest.unstable_mockModule('../controllers/indexerController.js', () => ({ getIndexerStatus: jest.fn(okHandler), getBorrowerEvents: jest.fn(okHandler), getLoanEvents: jest.fn(okHandler), @@ -25,30 +21,30 @@ jest.unstable_mockModule("../controllers/indexerController.js", () => ({ deleteWebhookSubscription: jest.fn(okHandler), })); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: mockQuery, default: { query: mockQuery, connect: jest.fn(), end: jest.fn() }, })); -const { default: indexerRoutes } = await import("../routes/indexerRoutes.js"); -const { errorHandler } = await import("../middleware/errorHandler.js"); +const { default: indexerRoutes } = await import('../routes/indexerRoutes.js'); +const { errorHandler } = await import('../middleware/errorHandler.js'); const originalApiKeys = process.env.INTERNAL_API_KEY; function buildApp(): Express { const app = express(); app.use(express.json()); - app.use("/api/indexer", indexerRoutes); + app.use('/api/indexer', indexerRoutes); app.use(errorHandler); return app; } -describe("indexer route API key scopes", () => { +describe('indexer route API key scopes', () => { beforeEach(() => { jest.clearAllMocks(); mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }); process.env.INTERNAL_API_KEY = - "admin:disputes:dispute-value,admin:webhooks:webhook-value,admin:indexer:indexer-value"; + 'admin:disputes:dispute-value,admin:webhooks:webhook-value,admin:indexer:indexer-value'; }); afterEach(() => { @@ -59,26 +55,26 @@ describe("indexer route API key scopes", () => { } }); - it("rejects a disputes-scoped key on POST /api/indexer/webhooks", async () => { + it('rejects a disputes-scoped key on POST /api/indexer/webhooks', async () => { await request(buildApp()) - .post("/api/indexer/webhooks") - .set("x-api-key", "dispute-value") + .post('/api/indexer/webhooks') + .set('x-api-key', 'dispute-value') .send({ - callbackUrl: "https://example.com/webhook", - eventTypes: ["LoanRequested"], + callbackUrl: 'https://example.com/webhook', + eventTypes: ['LoanRequested'], }) .expect(403); expect(mockCreateWebhookSubscription).not.toHaveBeenCalled(); }); - it("allows a webhooks-scoped key on POST /api/indexer/webhooks", async () => { + it('allows a webhooks-scoped key on POST /api/indexer/webhooks', async () => { await request(buildApp()) - .post("/api/indexer/webhooks") - .set("x-api-key", "webhook-value") + .post('/api/indexer/webhooks') + .set('x-api-key', 'webhook-value') .send({ - callbackUrl: "https://example.com/webhook", - eventTypes: ["LoanRequested"], + callbackUrl: 'https://example.com/webhook', + eventTypes: ['LoanRequested'], }) .expect(201); diff --git a/backend/src/__tests__/integration/indexer.integration.test.ts b/backend/src/__tests__/integration/indexer.integration.test.ts index 69692c18..3640d462 100644 --- a/backend/src/__tests__/integration/indexer.integration.test.ts +++ b/backend/src/__tests__/integration/indexer.integration.test.ts @@ -1,20 +1,20 @@ -import { EventIndexer } from "../../services/eventIndexer.js"; -import { query } from "../../db/connection.js"; -import { webhookService } from "../../services/webhookService.js"; -import { eventStreamService } from "../../services/eventStreamService.js"; -import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; +import { EventIndexer } from '../../services/eventIndexer.js'; +import { query } from '../../db/connection.js'; +import { webhookService } from '../../services/webhookService.js'; +import { eventStreamService } from '../../services/eventStreamService.js'; +import { Address, nativeToScVal, xdr } from '@stellar/stellar-sdk'; -describe("Integration: EventIndexer end-to-end", () => { - const runIntegration = process.env.RUN_INDEXER_INTEGRATION === "true"; +describe('Integration: EventIndexer end-to-end', () => { + const runIntegration = process.env.RUN_INDEXER_INTEGRATION === 'true'; beforeAll(async () => { if (!runIntegration) { return; } - await query("DELETE FROM contract_events"); - await query("DELETE FROM indexer_state"); - await query("INSERT INTO indexer_state (last_indexed_ledger) VALUES (0)"); + await query('DELETE FROM contract_events'); + await query('DELETE FROM indexer_state'); + await query('INSERT INTO indexer_state (last_indexed_ledger) VALUES (0)'); }); afterAll(async () => { @@ -22,83 +22,67 @@ describe("Integration: EventIndexer end-to-end", () => { return; } - await query("DELETE FROM contract_events"); - await query("DELETE FROM indexer_state"); + await query('DELETE FROM contract_events'); + await query('DELETE FROM indexer_state'); }); - it("should ingest LoanApproved event and persist it to contract_events", async () => { + it('should ingest LoanApproved event and persist it to contract_events', async () => { if (!runIntegration) { - console.warn( - "Skipping integration test because RUN_INDEXER_INTEGRATION != true", - ); + console.warn('Skipping integration test because RUN_INDEXER_INTEGRATION != true'); return; } const borrowerAddress = process.env.INTEGRATION_TEST_BORROWER_ADDRESS; if (!borrowerAddress) { - throw new Error("INTEGRATION_TEST_BORROWER_ADDRESS must be defined"); + throw new Error('INTEGRATION_TEST_BORROWER_ADDRESS must be defined'); } - const placeholderContractId = - process.env.LOAN_MANAGER_CONTRACT_ID ?? "CNTRACTID1"; + const placeholderContractId = process.env.LOAN_MANAGER_CONTRACT_ID ?? 'CNTRACTID1'; const loanId = 77; const dummyEvent = { id: `loan-approved-${Date.now()}`, - pagingToken: "dummy-token", - topic: [ - xdr.ScVal.scvSymbol("LoanApproved"), - nativeToScVal(loanId, { type: "u32" }), - ], + pagingToken: 'dummy-token', + topic: [xdr.ScVal.scvSymbol('LoanApproved'), nativeToScVal(loanId, { type: 'u32' })], value: nativeToScVal(Address.fromString(borrowerAddress), { - type: "address", + type: 'address', }), ledger: 1000, ledgerClosedAt: new Date().toISOString(), - txHash: "txhash-integration-001", + txHash: 'txhash-integration-001', contractId: placeholderContractId, }; - const dispatchSpy = jest - .spyOn(webhookService, "dispatch") - .mockImplementation(async () => { - return; - }); + const dispatchSpy = jest.spyOn(webhookService, 'dispatch').mockImplementation(async () => { + return; + }); const broadcastSpy = jest - .spyOn(eventStreamService, "broadcast") + .spyOn(eventStreamService, 'broadcast') .mockImplementation(async () => { return; }); - const indexer = new EventIndexer( - "https://example.com", - placeholderContractId, - ); + const indexer = new EventIndexer('https://example.com', placeholderContractId); // Bypass the actual Soroban RPC call for deterministic integration test - ( - indexer as unknown as { fetchEventsInRange: () => Promise } - ).fetchEventsInRange = async () => [dummyEvent]; + (indexer as unknown as { fetchEventsInRange: () => Promise }).fetchEventsInRange = + async () => [dummyEvent]; const chunkResult = await ( indexer as unknown as { - processChunk: ( - start: number, - end: number, - ) => Promise<{ insertedEvents: number }>; + processChunk: (start: number, end: number) => Promise<{ insertedEvents: number }>; } ).processChunk(1000, 1000); expect(chunkResult.insertedEvents).toBe(1); - const rows = await query( - "SELECT * FROM contract_events WHERE event_type = $1", - ["LoanApproved"], - ); + const rows = await query('SELECT * FROM contract_events WHERE event_type = $1', [ + 'LoanApproved', + ]); expect(rows.rows.length).toBe(1); const row = rows.rows[0]; expect(row.loan_id).toBe(loanId); expect(row.address).toBe(borrowerAddress); - expect(row.tx_hash).toBe("txhash-integration-001"); + expect(row.tx_hash).toBe('txhash-integration-001'); expect(dispatchSpy).toHaveBeenCalledTimes(1); expect(broadcastSpy).toHaveBeenCalledTimes(1); diff --git a/backend/src/__tests__/integration/loanDisputeFlow.test.ts b/backend/src/__tests__/integration/loanDisputeFlow.test.ts index 1b0ab58d..22f66cd8 100644 --- a/backend/src/__tests__/integration/loanDisputeFlow.test.ts +++ b/backend/src/__tests__/integration/loanDisputeFlow.test.ts @@ -1,53 +1,51 @@ -process.env.JWT_SECRET = "test-secret"; -process.env.INTERNAL_API_KEY = "test-api-key"; -process.env.NODE_ENV = "test"; +process.env.JWT_SECRET = 'test-secret'; +process.env.INTERNAL_API_KEY = 'test-api-key'; +process.env.NODE_ENV = 'test'; -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; const mockQuery = jest.fn<(...args: unknown[]) => Promise>(); const mockNotifyAdmins = jest.fn<(...args: unknown[]) => Promise>(); -const mockCreateNotification = - jest.fn<(...args: unknown[]) => Promise>(); +const mockCreateNotification = jest.fn<(...args: unknown[]) => Promise>(); -jest.unstable_mockModule("../../db/connection.js", () => ({ +jest.unstable_mockModule('../../db/connection.js', () => ({ query: mockQuery, default: { query: mockQuery, connect: jest.fn(), end: jest.fn() }, withTransaction: jest.fn(), })); -jest.unstable_mockModule("../../db/transaction.js", () => ({ +jest.unstable_mockModule('../../db/transaction.js', () => ({ withTransaction: jest.fn(), withStellarAndDbTransaction: jest.fn(), })); -jest.unstable_mockModule("../../services/notificationService.js", () => ({ +jest.unstable_mockModule('../../services/notificationService.js', () => ({ notificationService: { notifyAdmins: mockNotifyAdmins, createNotification: mockCreateNotification, }, })); -let request: typeof import("supertest"); -let jwt: typeof import("jsonwebtoken"); +let request: typeof import('supertest'); +let jwt: typeof import('jsonwebtoken'); let app: any; -const TEST_PUBLIC_KEY = - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; -const ADMIN_API_KEY = "test-api-key"; +const TEST_PUBLIC_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; +const ADMIN_API_KEY = 'test-api-key'; const LOAN_ID = 42; const DISPUTE_ID = 7; function mintToken(publicKey = TEST_PUBLIC_KEY) { return jwt.sign( - { publicKey, role: "borrower", scopes: ["read:loans", "write:loans"] }, + { publicKey, role: 'borrower', scopes: ['read:loans', 'write:loans'] }, process.env.JWT_SECRET!, - { algorithm: "HS256", expiresIn: "1h" }, + { algorithm: 'HS256', expiresIn: '1h' }, ); } -function dbRows(rows: object[], command = "SELECT") { +function dbRows(rows: object[], command = 'SELECT') { return { rows, rowCount: rows.length, command, oid: 0, fields: [] }; } -function dbOk(command = "INSERT") { +function dbOk(command = 'INSERT') { return { rows: [], rowCount: 1, command, oid: 0, fields: [] }; } @@ -60,24 +58,24 @@ async function seedDefaultedLoan(authToken: string) { .mockResolvedValueOnce(dbOk()); const loanRes = await request(app) - .post("/api/loans") - .set("Authorization", `Bearer ${authToken}`) + .post('/api/loans') + .set('Authorization', `Bearer ${authToken}`) .send({ amount: 1000, term: 12 }); expect(loanRes.status).toBe(200); const defaultRes = await request(app) .post(`/api/loans/${LOAN_ID}/mark-defaulted`) - .set("Authorization", `Bearer ${authToken}`) + .set('Authorization', `Bearer ${authToken}`) .send({ borrower: TEST_PUBLIC_KEY }); expect(defaultRes.status).toBe(200); } beforeAll(async () => { - ({ default: request } = await import("supertest")); - ({ default: jwt } = await import("jsonwebtoken")); - ({ default: app } = await import("../../app.js")); + ({ default: request } = await import('supertest')); + ({ default: jwt } = await import('jsonwebtoken')); + ({ default: app } = await import('../../app.js')); }); beforeEach(() => { @@ -88,18 +86,18 @@ beforeEach(() => { mockCreateNotification.mockResolvedValue({ id: 1, userId: TEST_PUBLIC_KEY, - type: "loan_defaulted", - title: "Dispute resolved", - message: "ok", + type: 'loan_defaulted', + title: 'Dispute resolved', + message: 'ok', loanId: LOAN_ID, read: false, - status: "unread", + status: 'unread', createdAt: new Date(), }); }); -describe("loan dispute resolution integration flow", () => { - it("contests a defaulted loan and confirms the default with borrower notification", async () => { +describe('loan dispute resolution integration flow', () => { + it('contests a defaulted loan and confirms the default with borrower notification', async () => { const authToken = mintToken(); await seedDefaultedLoan(authToken); @@ -111,14 +109,14 @@ describe("loan dispute resolution integration flow", () => { const contestRes = await request(app) .post(`/api/loans/${LOAN_ID}/contest-default`) - .set("Authorization", `Bearer ${authToken}`) - .send({ reason: "Indexer lag caused an incorrect default event." }); + .set('Authorization', `Bearer ${authToken}`) + .send({ reason: 'Indexer lag caused an incorrect default event.' }); expect(contestRes.status).toBe(200); expect(contestRes.body.success).toBe(true); expect(mockNotifyAdmins).toHaveBeenCalledWith( expect.objectContaining({ - title: "Loan Default Contested", + title: 'Loan Default Contested', loanId: LOAN_ID, }), ); @@ -130,20 +128,20 @@ describe("loan dispute resolution integration flow", () => { id: DISPUTE_ID, loan_id: LOAN_ID, borrower: TEST_PUBLIC_KEY, - status: "open", + status: 'open', }, ]), ) - .mockResolvedValueOnce(dbOk("UPDATE")) + .mockResolvedValueOnce(dbOk('UPDATE')) .mockResolvedValueOnce(dbOk()); const resolveRes = await request(app) .post(`/api/admin/loan-disputes/${DISPUTE_ID}/resolve`) - .set("x-api-key", ADMIN_API_KEY) + .set('x-api-key', ADMIN_API_KEY) .send({ - action: "confirm", - resolution: "Default was valid after ledger review.", - adminNote: "Collateral ratio stayed below threshold.", + action: 'confirm', + resolution: 'Default was valid after ledger review.', + adminNote: 'Collateral ratio stayed below threshold.', }); expect(resolveRes.status).toBe(200); @@ -151,7 +149,7 @@ describe("loan dispute resolution integration flow", () => { expect(mockCreateNotification).toHaveBeenCalledWith( expect.objectContaining({ userId: TEST_PUBLIC_KEY, - type: "loan_defaulted", + type: 'loan_defaulted', loanId: LOAN_ID, }), ); @@ -159,32 +157,23 @@ describe("loan dispute resolution integration flow", () => { expect(mockQuery.mock.calls).toEqual( expect.arrayContaining([ [ - expect.stringContaining("INSERT INTO loan_disputes"), - [ - String(LOAN_ID), - TEST_PUBLIC_KEY, - "Indexer lag caused an incorrect default event.", - ], + expect.stringContaining('INSERT INTO loan_disputes'), + [String(LOAN_ID), TEST_PUBLIC_KEY, 'Indexer lag caused an incorrect default event.'], ], [ - expect.stringContaining( - "UPDATE loan_disputes SET status = 'resolved'", - ), + expect.stringContaining("UPDATE loan_disputes SET status = 'resolved'"), [ - "Default was valid after ledger review.", - "Collateral ratio stayed below threshold.", + 'Default was valid after ledger review.', + 'Collateral ratio stayed below threshold.', String(DISPUTE_ID), ], ], - [ - expect.stringContaining("DefaultConfirmed"), - [LOAN_ID, TEST_PUBLIC_KEY], - ], + [expect.stringContaining('DefaultConfirmed'), [LOAN_ID, TEST_PUBLIC_KEY]], ]), ); }); - it("contests a defaulted loan and reverses the default with repayment notification", async () => { + it('contests a defaulted loan and reverses the default with repayment notification', async () => { const authToken = mintToken(); await seedDefaultedLoan(authToken); @@ -196,8 +185,8 @@ describe("loan dispute resolution integration flow", () => { const contestRes = await request(app) .post(`/api/loans/${LOAN_ID}/contest-default`) - .set("Authorization", `Bearer ${authToken}`) - .send({ reason: "The repayment posted before the indexer caught up." }); + .set('Authorization', `Bearer ${authToken}`) + .send({ reason: 'The repayment posted before the indexer caught up.' }); expect(contestRes.status).toBe(200); expect(contestRes.body.success).toBe(true); @@ -210,20 +199,20 @@ describe("loan dispute resolution integration flow", () => { id: DISPUTE_ID, loan_id: LOAN_ID, borrower: TEST_PUBLIC_KEY, - status: "open", + status: 'open', }, ]), ) - .mockResolvedValueOnce(dbOk("UPDATE")) + .mockResolvedValueOnce(dbOk('UPDATE')) .mockResolvedValueOnce(dbOk()); const resolveRes = await request(app) .post(`/api/admin/loan-disputes/${DISPUTE_ID}/resolve`) - .set("x-api-key", ADMIN_API_KEY) + .set('x-api-key', ADMIN_API_KEY) .send({ - action: "reverse", - resolution: "Default reversed after repayment verification.", - adminNote: "Loan events were replayed successfully.", + action: 'reverse', + resolution: 'Default reversed after repayment verification.', + adminNote: 'Loan events were replayed successfully.', }); expect(resolveRes.status).toBe(200); @@ -231,7 +220,7 @@ describe("loan dispute resolution integration flow", () => { expect(mockCreateNotification).toHaveBeenCalledWith( expect.objectContaining({ userId: TEST_PUBLIC_KEY, - type: "repayment_confirmed", + type: 'repayment_confirmed', loanId: LOAN_ID, }), ); @@ -239,27 +228,18 @@ describe("loan dispute resolution integration flow", () => { expect(mockQuery.mock.calls).toEqual( expect.arrayContaining([ [ - expect.stringContaining("INSERT INTO loan_disputes"), - [ - String(LOAN_ID), - TEST_PUBLIC_KEY, - "The repayment posted before the indexer caught up.", - ], + expect.stringContaining('INSERT INTO loan_disputes'), + [String(LOAN_ID), TEST_PUBLIC_KEY, 'The repayment posted before the indexer caught up.'], ], [ - expect.stringContaining( - "UPDATE loan_disputes SET status = 'resolved'", - ), + expect.stringContaining("UPDATE loan_disputes SET status = 'resolved'"), [ - "Default reversed after repayment verification.", - "Loan events were replayed successfully.", + 'Default reversed after repayment verification.', + 'Loan events were replayed successfully.', String(DISPUTE_ID), ], ], - [ - expect.stringContaining("DefaultReversed"), - [LOAN_ID, TEST_PUBLIC_KEY], - ], + [expect.stringContaining('DefaultReversed'), [LOAN_ID, TEST_PUBLIC_KEY]], ]), ); }); diff --git a/backend/src/__tests__/integration/remittance.integration.test.ts b/backend/src/__tests__/integration/remittance.integration.test.ts index d19d7e18..1fc70f44 100644 --- a/backend/src/__tests__/integration/remittance.integration.test.ts +++ b/backend/src/__tests__/integration/remittance.integration.test.ts @@ -1,145 +1,133 @@ -import request from "supertest"; -import { query } from "../../db/connection.js"; -import { sorobanService } from "../../services/sorobanService.js"; -import { app } from "../../app.js"; +import request from 'supertest'; +import { query } from '../../db/connection.js'; +import { sorobanService } from '../../services/sorobanService.js'; +import { app } from '../../app.js'; -jest.mock("../../services/sorobanService.js"); +jest.mock('../../services/sorobanService.js'); -describe("Integration: Remittance Submit Flow", () => { +describe('Integration: Remittance Submit Flow', () => { let authToken: string; let remittanceId: string; - const senderAddress = "GBTEST123SENDER456STELLAR789ADDRESS000"; - const recipientAddress = "GBTEST123RECIPIENT456STELLAR789ADDRESS"; - const validSignedXdr = "AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + const senderAddress = 'GBTEST123SENDER456STELLAR789ADDRESS000'; + const recipientAddress = 'GBTEST123RECIPIENT456STELLAR789ADDRESS'; + const validSignedXdr = + 'AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; beforeAll(async () => { authToken = `Bearer test-token-${senderAddress}`; - await query("DELETE FROM remittances WHERE sender_id = $1", [senderAddress]); + await query('DELETE FROM remittances WHERE sender_id = $1', [senderAddress]); const result = await query( `INSERT INTO remittances (id, sender_id, recipient_address, amount, from_currency, to_currency, status, unsigned_xdr, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) RETURNING id`, [ - "test-remittance-1", + 'test-remittance-1', senderAddress, recipientAddress, - "100.00", - "USD", - "XLM", - "pending", - "unsigned-xdr-here", - ] + '100.00', + 'USD', + 'XLM', + 'pending', + 'unsigned-xdr-here', + ], ); remittanceId = result.rows[0].id; }); afterAll(async () => { - await query("DELETE FROM remittances WHERE sender_id = $1", [senderAddress]); + await query('DELETE FROM remittances WHERE sender_id = $1', [senderAddress]); }); beforeEach(() => { jest.clearAllMocks(); }); - it("should submit remittance with valid signed XDR and return transaction hash", async () => { - const mockTxHash = "abc123def456ghi789"; + it('should submit remittance with valid signed XDR and return transaction hash', async () => { + const mockTxHash = 'abc123def456ghi789'; (sorobanService.submitSignedTx as jest.Mock).mockResolvedValue({ txHash: mockTxHash, - status: "SUCCESS", + status: 'SUCCESS', }); const response = await request(app) .post(`/api/remittances/${remittanceId}/submit`) - .set("Authorization", authToken) + .set('Authorization', authToken) .send({ signedXdr: validSignedXdr }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.data.txHash).toBe(mockTxHash); - expect(response.body.data.status).toBe("completed"); + expect(response.body.data.status).toBe('completed'); expect(sorobanService.submitSignedTx).toHaveBeenCalledWith(validSignedXdr); - const dbResult = await query( - "SELECT status, tx_hash FROM remittances WHERE id = $1", - [remittanceId] - ); - expect(dbResult.rows[0].status).toBe("completed"); + const dbResult = await query('SELECT status, tx_hash FROM remittances WHERE id = $1', [ + remittanceId, + ]); + expect(dbResult.rows[0].status).toBe('completed'); expect(dbResult.rows[0].tx_hash).toBe(mockTxHash); }); - it("should reject invalid XDR with 400 error", async () => { - (sorobanService.submitSignedTx as jest.Mock).mockRejectedValue( - new Error("Invalid XDR format") - ); + it('should reject invalid XDR with 400 error', async () => { + (sorobanService.submitSignedTx as jest.Mock).mockRejectedValue(new Error('Invalid XDR format')); const response = await request(app) .post(`/api/remittances/${remittanceId}/submit`) - .set("Authorization", authToken) - .send({ signedXdr: "invalid-xdr" }) + .set('Authorization', authToken) + .send({ signedXdr: 'invalid-xdr' }) .expect(500); expect(response.body.success).toBe(false); - const dbResult = await query( - "SELECT status FROM remittances WHERE id = $1", - [remittanceId] - ); - expect(dbResult.rows[0].status).toBe("failed"); + const dbResult = await query('SELECT status FROM remittances WHERE id = $1', [remittanceId]); + expect(dbResult.rows[0].status).toBe('failed'); }); - it("should reject already-completed remittance with 400 error", async () => { - await query( - "UPDATE remittances SET status = $1 WHERE id = $2", - ["completed", remittanceId] - ); + it('should reject already-completed remittance with 400 error', async () => { + await query('UPDATE remittances SET status = $1 WHERE id = $2', ['completed', remittanceId]); const response = await request(app) .post(`/api/remittances/${remittanceId}/submit`) - .set("Authorization", authToken) + .set('Authorization', authToken) .send({ signedXdr: validSignedXdr }) .expect(400); expect(response.body.success).toBe(false); - expect(response.body.message).toContain("already been submitted"); + expect(response.body.message).toContain('already been submitted'); - await query( - "UPDATE remittances SET status = $1 WHERE id = $2", - ["pending", remittanceId] - ); + await query('UPDATE remittances SET status = $1 WHERE id = $2', ['pending', remittanceId]); }); - it("should reject submission from wrong sender with 403 error", async () => { + it('should reject submission from wrong sender with 403 error', async () => { const wrongSenderToken = `Bearer test-token-GBWRONGSENDER`; const response = await request(app) .post(`/api/remittances/${remittanceId}/submit`) - .set("Authorization", wrongSenderToken) + .set('Authorization', wrongSenderToken) .send({ signedXdr: validSignedXdr }) .expect(403); expect(response.body.success).toBe(false); - expect(response.body.message).toContain("do not have access"); + expect(response.body.message).toContain('do not have access'); }); - it("should handle Stellar network rejection with 502 error message", async () => { - const stellarError = new Error("Stellar network rejected transaction"); + it('should handle Stellar network rejection with 502 error message', async () => { + const stellarError = new Error('Stellar network rejected transaction'); (sorobanService.submitSignedTx as jest.Mock).mockRejectedValue(stellarError); const response = await request(app) .post(`/api/remittances/${remittanceId}/submit`) - .set("Authorization", authToken) + .set('Authorization', authToken) .send({ signedXdr: validSignedXdr }) .expect(500); expect(response.body.success).toBe(false); expect(sorobanService.submitSignedTx).toHaveBeenCalledWith(validSignedXdr); - const dbResult = await query( - "SELECT status, error_message FROM remittances WHERE id = $1", - [remittanceId] - ); - expect(dbResult.rows[0].status).toBe("failed"); - expect(dbResult.rows[0].error_message).toContain("Stellar network"); + const dbResult = await query('SELECT status, error_message FROM remittances WHERE id = $1', [ + remittanceId, + ]); + expect(dbResult.rows[0].status).toBe('failed'); + expect(dbResult.rows[0].error_message).toContain('Stellar network'); }); }); diff --git a/backend/src/__tests__/jobMetrics.test.ts b/backend/src/__tests__/jobMetrics.test.ts index 7c554a73..8a7047b8 100644 --- a/backend/src/__tests__/jobMetrics.test.ts +++ b/backend/src/__tests__/jobMetrics.test.ts @@ -1,13 +1,13 @@ -import { jobMetricsService } from "../services/jobMetricsService.js"; +import { jobMetricsService } from '../services/jobMetricsService.js'; -describe("jobMetricsService", () => { +describe('jobMetricsService', () => { beforeEach(() => { jobMetricsService.resetAll(); }); - it("initializes job metrics", () => { - jobMetricsService.initializeJob("testJob"); - const metrics = jobMetricsService.getJobMetrics("testJob"); + it('initializes job metrics', () => { + jobMetricsService.initializeJob('testJob'); + const metrics = jobMetricsService.getJobMetrics('testJob'); expect(metrics).not.toBeNull(); expect(metrics?.lastRunAt).toBeNull(); @@ -18,9 +18,9 @@ describe("jobMetricsService", () => { expect(metrics?.durationMs).toBeNull(); }); - it("records successful job run", () => { - jobMetricsService.recordSuccess("testJob", 1500); - const metrics = jobMetricsService.getJobMetrics("testJob"); + it('records successful job run', () => { + jobMetricsService.recordSuccess('testJob', 1500); + const metrics = jobMetricsService.getJobMetrics('testJob'); expect(metrics).not.toBeNull(); expect(metrics?.lastRunAt).not.toBeNull(); @@ -31,38 +31,38 @@ describe("jobMetricsService", () => { expect(metrics?.durationMs).toBe(1500); }); - it("records failed job run", () => { - const error = new Error("Test error"); - jobMetricsService.recordFailure("testJob", error, 2000); - const metrics = jobMetricsService.getJobMetrics("testJob"); + it('records failed job run', () => { + const error = new Error('Test error'); + jobMetricsService.recordFailure('testJob', error, 2000); + const metrics = jobMetricsService.getJobMetrics('testJob'); expect(metrics).not.toBeNull(); expect(metrics?.lastRunAt).not.toBeNull(); expect(metrics?.lastSuccessAt).toBeNull(); - expect(metrics?.lastError).toBe("Test error"); + expect(metrics?.lastError).toBe('Test error'); expect(metrics?.runsTotal).toBe(1); expect(metrics?.failuresTotal).toBe(1); expect(metrics?.durationMs).toBe(2000); }); - it("tracks multiple runs", () => { - jobMetricsService.recordSuccess("testJob", 1000); - jobMetricsService.recordSuccess("testJob", 1200); - jobMetricsService.recordFailure("testJob", "Error", 1500); + it('tracks multiple runs', () => { + jobMetricsService.recordSuccess('testJob', 1000); + jobMetricsService.recordSuccess('testJob', 1200); + jobMetricsService.recordFailure('testJob', 'Error', 1500); - const metrics = jobMetricsService.getJobMetrics("testJob"); + const metrics = jobMetricsService.getJobMetrics('testJob'); expect(metrics?.runsTotal).toBe(3); expect(metrics?.failuresTotal).toBe(1); expect(metrics?.durationMs).toBe(1500); }); - it("tracks multiple jobs independently", () => { - jobMetricsService.recordSuccess("job1", 1000); - jobMetricsService.recordFailure("job2", "Error", 2000); + it('tracks multiple jobs independently', () => { + jobMetricsService.recordSuccess('job1', 1000); + jobMetricsService.recordFailure('job2', 'Error', 2000); - const metrics1 = jobMetricsService.getJobMetrics("job1"); - const metrics2 = jobMetricsService.getJobMetrics("job2"); + const metrics1 = jobMetricsService.getJobMetrics('job1'); + const metrics2 = jobMetricsService.getJobMetrics('job2'); expect(metrics1?.runsTotal).toBe(1); expect(metrics1?.failuresTotal).toBe(0); @@ -70,9 +70,9 @@ describe("jobMetricsService", () => { expect(metrics2?.failuresTotal).toBe(1); }); - it("returns all metrics snapshot", () => { - jobMetricsService.recordSuccess("job1", 1000); - jobMetricsService.recordFailure("job2", "Error", 2000); + it('returns all metrics snapshot', () => { + jobMetricsService.recordSuccess('job1', 1000); + jobMetricsService.recordFailure('job2', 'Error', 2000); const allMetrics = jobMetricsService.getAllMetrics(); @@ -81,17 +81,17 @@ describe("jobMetricsService", () => { expect(allMetrics.job2?.runsTotal).toBe(1); }); - it("resets individual job metrics", () => { - jobMetricsService.recordSuccess("testJob", 1000); - jobMetricsService.resetJob("testJob"); + it('resets individual job metrics', () => { + jobMetricsService.recordSuccess('testJob', 1000); + jobMetricsService.resetJob('testJob'); - const metrics = jobMetricsService.getJobMetrics("testJob"); + const metrics = jobMetricsService.getJobMetrics('testJob'); expect(metrics).toBeNull(); }); - it("resets all metrics", () => { - jobMetricsService.recordSuccess("job1", 1000); - jobMetricsService.recordSuccess("job2", 2000); + it('resets all metrics', () => { + jobMetricsService.recordSuccess('job1', 1000); + jobMetricsService.recordSuccess('job2', 2000); jobMetricsService.resetAll(); const allMetrics = jobMetricsService.getAllMetrics(); diff --git a/backend/src/__tests__/loanConfig.test.ts b/backend/src/__tests__/loanConfig.test.ts index a4edac00..a451ecc9 100644 --- a/backend/src/__tests__/loanConfig.test.ts +++ b/backend/src/__tests__/loanConfig.test.ts @@ -1,6 +1,6 @@ -import { validateLoanConfig } from "../config/loanConfig.js"; +import { validateLoanConfig } from '../config/loanConfig.js'; -describe("Loan config startup validation", () => { +describe('Loan config startup validation', () => { const originalEnv = { LOAN_MIN_SCORE: process.env.LOAN_MIN_SCORE, LOAN_MAX_AMOUNT: process.env.LOAN_MAX_AMOUNT, @@ -11,57 +11,52 @@ describe("Loan config startup validation", () => { afterEach(() => { process.env.LOAN_MIN_SCORE = originalEnv.LOAN_MIN_SCORE; process.env.LOAN_MAX_AMOUNT = originalEnv.LOAN_MAX_AMOUNT; - process.env.LOAN_INTEREST_RATE_PERCENT = - originalEnv.LOAN_INTEREST_RATE_PERCENT; + process.env.LOAN_INTEREST_RATE_PERCENT = originalEnv.LOAN_INTEREST_RATE_PERCENT; process.env.CREDIT_SCORE_THRESHOLD = originalEnv.CREDIT_SCORE_THRESHOLD; }); - it("passes when required values are valid", () => { - process.env.LOAN_MIN_SCORE = "520"; - process.env.LOAN_MAX_AMOUNT = "100000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "15"; - process.env.CREDIT_SCORE_THRESHOLD = "650"; + it('passes when required values are valid', () => { + process.env.LOAN_MIN_SCORE = '520'; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '15'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; expect(() => validateLoanConfig()).not.toThrow(); }); - it("throws when required env var is missing", () => { + it('throws when required env var is missing', () => { delete process.env.LOAN_MIN_SCORE; - process.env.LOAN_MAX_AMOUNT = "100000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "15"; - process.env.CREDIT_SCORE_THRESHOLD = "650"; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '15'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; - expect(() => validateLoanConfig()).toThrow("LOAN_MIN_SCORE is required"); + expect(() => validateLoanConfig()).toThrow('LOAN_MIN_SCORE is required'); }); - it("throws when numeric value is invalid", () => { - process.env.LOAN_MIN_SCORE = "0"; - process.env.LOAN_MAX_AMOUNT = "100000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "15"; - process.env.CREDIT_SCORE_THRESHOLD = "650"; + it('throws when numeric value is invalid', () => { + process.env.LOAN_MIN_SCORE = '0'; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '15'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; - expect(() => validateLoanConfig()).toThrow( - "LOAN_MIN_SCORE must be between 300 and 850", - ); + expect(() => validateLoanConfig()).toThrow('LOAN_MIN_SCORE must be between 300 and 850'); }); - it("accepts decimal interest rate percent", () => { - process.env.LOAN_MIN_SCORE = "500"; - process.env.LOAN_MAX_AMOUNT = "100000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "14.1"; - process.env.CREDIT_SCORE_THRESHOLD = "650"; + it('accepts decimal interest rate percent', () => { + process.env.LOAN_MIN_SCORE = '500'; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '14.1'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; expect(() => validateLoanConfig()).not.toThrow(); }); - it("throws when non-numeric value is provided for interest rate", () => { - process.env.LOAN_MIN_SCORE = "500"; - process.env.LOAN_MAX_AMOUNT = "100000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "14.1abc"; - process.env.CREDIT_SCORE_THRESHOLD = "650"; + it('throws when non-numeric value is provided for interest rate', () => { + process.env.LOAN_MIN_SCORE = '500'; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '14.1abc'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; - expect(() => validateLoanConfig()).toThrow( - "LOAN_INTEREST_RATE_PERCENT must be a valid number", - ); + expect(() => validateLoanConfig()).toThrow('LOAN_INTEREST_RATE_PERCENT must be a valid number'); }); }); diff --git a/backend/src/__tests__/loanDispute.test.ts b/backend/src/__tests__/loanDispute.test.ts index d2dba73f..d6fe08db 100644 --- a/backend/src/__tests__/loanDispute.test.ts +++ b/backend/src/__tests__/loanDispute.test.ts @@ -1,57 +1,56 @@ // ─── Env vars MUST be set before any app imports ───────────────────────────── -process.env.JWT_SECRET = "test-secret"; -process.env.INTERNAL_API_KEY = "test-api-key"; -process.env.NODE_ENV = "test"; +process.env.JWT_SECRET = 'test-secret'; +process.env.INTERNAL_API_KEY = 'test-api-key'; +process.env.NODE_ENV = 'test'; -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; // ESM-compatible mocking const mockQuery = jest.fn<(...args: unknown[]) => Promise>(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: mockQuery, default: { query: mockQuery, connect: jest.fn(), end: jest.fn() }, withTransaction: jest.fn(), })); -jest.unstable_mockModule("../db/transaction.js", () => ({ +jest.unstable_mockModule('../db/transaction.js', () => ({ withTransaction: jest.fn(), withStellarAndDbTransaction: jest.fn(), })); -let request: typeof import("supertest"); -let jwt: typeof import("jsonwebtoken"); +let request: typeof import('supertest'); +let jwt: typeof import('jsonwebtoken'); let app: any; // Dynamic imports after mocks beforeAll(async () => { - ({ default: request } = await import("supertest")); - ({ default: jwt } = await import("jsonwebtoken")); - ({ default: app } = await import("../app.js")); + ({ default: request } = await import('supertest')); + ({ default: jwt } = await import('jsonwebtoken')); + ({ default: app } = await import('../app.js')); }); // ─── Constants ──────────────────────────────────────────────────────────────── // Real Stellar-format public key so any key-format validation passes -const TEST_PUBLIC_KEY = - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; -const ADMIN_API_KEY = "test-api-key"; +const TEST_PUBLIC_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; +const ADMIN_API_KEY = 'test-api-key'; const LOAN_ID = 42; const DISPUTE_ID = 7; // ─── Helpers ────────────────────────────────────────────────────────────────── function mintToken(publicKey = TEST_PUBLIC_KEY) { return jwt.sign( - { publicKey, role: "borrower", scopes: ["read:loans", "write:loans"] }, + { publicKey, role: 'borrower', scopes: ['read:loans', 'write:loans'] }, process.env.JWT_SECRET!, - { algorithm: "HS256", expiresIn: "1h" }, + { algorithm: 'HS256', expiresIn: '1h' }, ); } /** Shorthand for a resolved pg QueryResult with rows */ -function dbRows(rows: object[], command = "SELECT") { +function dbRows(rows: object[], command = 'SELECT') { return { rows, rowCount: rows.length, command, oid: 0, fields: [] }; } /** Shorthand for a resolved pg QueryResult with no rows */ -function dbOk(command = "INSERT") { +function dbOk(command = 'INSERT') { return { rows: [], rowCount: 1, command, oid: 0, fields: [] }; } @@ -65,9 +64,9 @@ let disputeId = DISPUTE_ID; beforeAll(async () => { // Wait for dynamic imports if (!request || !jwt || !app) { - ({ default: request } = await import("supertest")); - ({ default: jwt } = await import("jsonwebtoken")); - ({ default: app } = await import("../app.js")); + ({ default: request } = await import('supertest')); + ({ default: jwt } = await import('jsonwebtoken')); + ({ default: app } = await import('../app.js')); } authToken = mintToken(); @@ -85,25 +84,21 @@ beforeAll(async () => { .mockResolvedValueOnce(dbOk()); const loanRes = await request(app) - .post("/api/loans") - .set("Authorization", `Bearer ${authToken}`) + .post('/api/loans') + .set('Authorization', `Bearer ${authToken}`) .send({ amount: 1000, term: 12 }); if (loanRes.status !== 200) { - console.error("createTestLoan failed:", loanRes.status, loanRes.body); + console.error('createTestLoan failed:', loanRes.status, loanRes.body); } const defaultRes = await request(app) .post(`/api/loans/${defaultedLoanId}/mark-defaulted`) - .set("Authorization", `Bearer ${authToken}`) + .set('Authorization', `Bearer ${authToken}`) .send({ borrower: TEST_PUBLIC_KEY }); if (defaultRes.status !== 200) { - console.error( - "markLoanDefaulted failed:", - defaultRes.status, - defaultRes.body, - ); + console.error('markLoanDefaulted failed:', defaultRes.status, defaultRes.body); } }, 15000); @@ -112,8 +107,8 @@ afterAll(() => { }); // ─── Tests ──────────────────────────────────────────────────────────────────── -describe("Loan Dispute/Appeal Mechanism", () => { - it("should reject contest if loan is not defaulted", async () => { +describe('Loan Dispute/Appeal Mechanism', () => { + it('should reject contest if loan is not defaulted', async () => { /** * POST /api/loans/9999/contest-default * requireLoanBorrowerAccess for loanId=9999: @@ -124,14 +119,14 @@ describe("Loan Dispute/Appeal Mechanism", () => { mockQuery.mockResolvedValueOnce(dbRows([])); // [1] loan not found const res = await request(app) - .post("/api/loans/9999/contest-default") - .set("Authorization", `Bearer ${authToken}`) - .send({ reason: "Test reason" }); + .post('/api/loans/9999/contest-default') + .set('Authorization', `Bearer ${authToken}`) + .send({ reason: 'Test reason' }); expect(res.status).toBeGreaterThanOrEqual(400); }); - it("should allow borrower to contest a defaulted loan", async () => { + it('should allow borrower to contest a defaulted loan', async () => { /** * POST /api/loans/42/contest-default * requireLoanBorrowerAccess: @@ -150,8 +145,8 @@ describe("Loan Dispute/Appeal Mechanism", () => { const res = await request(app) .post(`/api/loans/${defaultedLoanId}/contest-default`) - .set("Authorization", `Bearer ${authToken}`) - .send({ reason: "Indexer lag caused incorrect default." }); + .set('Authorization', `Bearer ${authToken}`) + .send({ reason: 'Indexer lag caused incorrect default.' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); @@ -159,7 +154,7 @@ describe("Loan Dispute/Appeal Mechanism", () => { disputeId = res.body.disputeId ?? disputeId; }); - it("should freeze penalty accrual during dispute", async () => { + it('should freeze penalty accrual during dispute', async () => { /** * GET /api/loans/42 * requireLoanBorrowerAccess: @@ -177,8 +172,8 @@ describe("Loan Dispute/Appeal Mechanism", () => { dbRows([ // [2] all loan events { - event_type: "LoanRequested", - amount: "1000", + event_type: 'LoanRequested', + amount: '1000', ledger: 100, ledger_closed_at: new Date().toISOString(), tx_hash: null, @@ -186,8 +181,8 @@ describe("Loan Dispute/Appeal Mechanism", () => { term_ledgers: null, }, { - event_type: "LoanApproved", - amount: "1000", + event_type: 'LoanApproved', + amount: '1000', ledger: 101, ledger_closed_at: new Date().toISOString(), tx_hash: null, @@ -195,7 +190,7 @@ describe("Loan Dispute/Appeal Mechanism", () => { term_ledgers: 17280, }, { - event_type: "LoanDefaulted", + event_type: 'LoanDefaulted', amount: null, ledger: 200, ledger_closed_at: new Date().toISOString(), @@ -207,19 +202,17 @@ describe("Loan Dispute/Appeal Mechanism", () => { ) .mockResolvedValueOnce(dbRows([{ last_indexed_ledger: 300 }])) // [3] latest ledger .mockResolvedValueOnce(dbRows([{ created_at: new Date().toISOString() }])) // [4] open dispute - .mockResolvedValueOnce( - dbRows([{ ledger: 200, ledger_closed_at: new Date().toISOString() }]), - ); // [5] freeze ledger + .mockResolvedValueOnce(dbRows([{ ledger: 200, ledger_closed_at: new Date().toISOString() }])); // [5] freeze ledger const res = await request(app) .get(`/api/loans/${defaultedLoanId}`) - .set("Authorization", `Bearer ${authToken}`); + .set('Authorization', `Bearer ${authToken}`); expect(res.status).toBe(200); expect(res.body.summary?.disputeFrozen).toBe(true); }); - it("should allow admin to resolve dispute as confirm", async () => { + it('should allow admin to resolve dispute as confirm', async () => { /** * POST /api/admin/loan-disputes/:disputeId/resolve (no loanAccess middleware) * resolveLoanDispute: @@ -234,23 +227,23 @@ describe("Loan Dispute/Appeal Mechanism", () => { id: disputeId, loan_id: LOAN_ID, borrower: TEST_PUBLIC_KEY, - status: "open", + status: 'open', }, ]), ) // [1] - .mockResolvedValueOnce(dbOk("UPDATE")) // [2] + .mockResolvedValueOnce(dbOk('UPDATE')) // [2] .mockResolvedValueOnce(dbOk()); // [3] const res = await request(app) .post(`/api/admin/loan-disputes/${disputeId}/resolve`) - .set("x-api-key", ADMIN_API_KEY) - .send({ action: "confirm", resolution: "Default was valid." }); + .set('x-api-key', ADMIN_API_KEY) + .send({ action: 'confirm', resolution: 'Default was valid.' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); - it("should allow admin to resolve dispute as reverse", async () => { + it('should allow admin to resolve dispute as reverse', async () => { /** * resolveLoanDispute: * [1] SELECT loan_disputes WHERE id = disputeId AND status='open' → found @@ -264,17 +257,17 @@ describe("Loan Dispute/Appeal Mechanism", () => { id: disputeId, loan_id: LOAN_ID, borrower: TEST_PUBLIC_KEY, - status: "open", + status: 'open', }, ]), ) // [1] - .mockResolvedValueOnce(dbOk("UPDATE")) // [2] + .mockResolvedValueOnce(dbOk('UPDATE')) // [2] .mockResolvedValueOnce(dbOk()); // [3] const res = await request(app) .post(`/api/admin/loan-disputes/${disputeId}/resolve`) - .set("x-api-key", ADMIN_API_KEY) - .send({ action: "reverse", resolution: "Default was incorrect." }); + .set('x-api-key', ADMIN_API_KEY) + .send({ action: 'reverse', resolution: 'Default was incorrect.' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); diff --git a/backend/src/__tests__/loanEndpoints.test.ts b/backend/src/__tests__/loanEndpoints.test.ts index a33f83df..f264d0d7 100644 --- a/backend/src/__tests__/loanEndpoints.test.ts +++ b/backend/src/__tests__/loanEndpoints.test.ts @@ -6,10 +6,10 @@ import { generateJwtToken } from "../services/authService.js"; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -const VALID_API_KEY = "test-internal-key"; +const VALID_API_KEY = 'test-internal-key'; const TEST_BORROWER = Keypair.random().publicKey(); -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; process.env.INTERNAL_API_KEY = VALID_API_KEY; const mockQuery: jest.MockedFunction< @@ -23,23 +23,21 @@ const mockClient = { release: mockRelease, }; -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, - getClient: jest - .fn<() => Promise>() - .mockResolvedValue(mockClient), + getClient: jest.fn<() => Promise>().mockResolvedValue(mockClient), closePool: jest.fn(), withTransaction: jest.fn(), })); // Mock CacheService to prevent Redis connections -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); @@ -100,11 +98,9 @@ const mockBuildLiquidateTx = >(); const mockSubmitSignedTx = jest.fn< - ( - signedTxXdr: string, - ) => Promise<{ txHash: string; status: string; resultXdr?: string }> + (signedTxXdr: string) => Promise<{ txHash: string; status: string; resultXdr?: string }> >(); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { buildRequestLoanTx: mockBuildRequestLoanTx, buildRepayTx: mockBuildRepayTx, @@ -117,9 +113,9 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ }, })); -await import("../db/connection.js"); -await import("../services/sorobanService.js"); -const { default: app } = await import("../app.js"); +await import('../db/connection.js'); +await import('../services/sorobanService.js'); +const { default: app } = await import('../app.js'); const mockedQuery = mockQuery; @@ -150,7 +146,7 @@ afterAll(() => { // --------------------------------------------------------------------------- // GET /api/loans/config // --------------------------------------------------------------------------- -describe("GET /api/loans/config", () => { +describe('GET /api/loans/config', () => { const originalMinScore = process.env.LOAN_MIN_SCORE; const originalMaxAmount = process.env.LOAN_MAX_AMOUNT; const originalInterest = process.env.LOAN_INTEREST_RATE_PERCENT; @@ -175,13 +171,13 @@ describe("GET /api/loans/config", () => { } }); - it("should return configured env values when all required vars are set", async () => { - process.env.LOAN_MIN_SCORE = "500"; - process.env.LOAN_MAX_AMOUNT = "50000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "12"; - process.env.CREDIT_SCORE_THRESHOLD = "600"; + it('should return configured env values when all required vars are set', async () => { + process.env.LOAN_MIN_SCORE = '500'; + process.env.LOAN_MAX_AMOUNT = '50000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '12'; + process.env.CREDIT_SCORE_THRESHOLD = '600'; - const response = await request(app).get("/api/loans/config"); + const response = await request(app).get('/api/loans/config'); expect(response.status).toBe(200); expect(response.body).toEqual({ @@ -195,13 +191,13 @@ describe("GET /api/loans/config", () => { }); }); - it("should return configured env values", async () => { - process.env.LOAN_MIN_SCORE = "620"; - process.env.LOAN_MAX_AMOUNT = "65000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "14"; - process.env.CREDIT_SCORE_THRESHOLD = "640"; + it('should return configured env values', async () => { + process.env.LOAN_MIN_SCORE = '620'; + process.env.LOAN_MAX_AMOUNT = '65000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '14'; + process.env.CREDIT_SCORE_THRESHOLD = '640'; - const response = await request(app).get("/api/loans/config"); + const response = await request(app).get('/api/loans/config'); expect(response.status).toBe(200); expect(response.body).toEqual({ @@ -219,43 +215,43 @@ describe("GET /api/loans/config", () => { // --------------------------------------------------------------------------- // POST /api/loans/request // --------------------------------------------------------------------------- -describe("POST /api/loans/request", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/request', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/request") + .post('/api/loans/request') .send({ amount: 1000, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(401); }); - it("should reject when borrowerPublicKey does not match JWT", async () => { + it('should reject when borrowerPublicKey does not match JWT', async () => { const otherBorrower = Keypair.random().publicKey(); const response = await request(app) - .post("/api/loans/request") + .post('/api/loans/request') .set(bearer(TEST_BORROWER)) .send({ amount: 1000, borrowerPublicKey: otherBorrower }); expect(response.status).toBe(403); }); - it("should return unsigned XDR for valid request", async () => { + it('should return unsigned XDR for valid request', async () => { mockBuildRequestLoanTx.mockResolvedValueOnce({ - unsignedTxXdr: "AAAA...base64xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'AAAA...base64xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/request") + .post('/api/loans/request') .set(bearer(TEST_BORROWER)) .send({ amount: 1000, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.unsignedTxXdr).toBe("AAAA...base64xdr"); + expect(response.body.unsignedTxXdr).toBe('AAAA...base64xdr'); expect(response.body.networkPassphrase).toBeDefined(); }); - it("should reject missing amount", async () => { + it('should reject missing amount', async () => { const response = await request(app) - .post("/api/loans/request") + .post('/api/loans/request') .set(bearer(TEST_BORROWER)) .send({ borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(400); @@ -265,61 +261,61 @@ describe("POST /api/loans/request", () => { // --------------------------------------------------------------------------- // POST /api/loans/submit // --------------------------------------------------------------------------- -describe("POST /api/loans/submit", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/submit', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/submit") - .send({ signedTxXdr: "c2lnbmVkLXhkcg==" }); + .post('/api/loans/submit') + .send({ signedTxXdr: 'c2lnbmVkLXhkcg==' }); expect(response.status).toBe(401); }); - it("should submit a signed transaction", async () => { + it('should submit a signed transaction', async () => { mockSubmitSignedTx.mockResolvedValueOnce({ - txHash: "abc123hash", - status: "SUCCESS", + txHash: 'abc123hash', + status: 'SUCCESS', }); const response = await request(app) - .post("/api/loans/submit") + .post('/api/loans/submit') .set(bearer(TEST_BORROWER)) - .send({ signedTxXdr: "c2lnbmVkLXhkci1kYXRh" }); + .send({ signedTxXdr: 'c2lnbmVkLXhkci1kYXRh' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.txHash).toBe("abc123hash"); - expect(response.body.status).toBe("SUCCESS"); + expect(response.body.txHash).toBe('abc123hash'); + expect(response.body.status).toBe('SUCCESS'); }); - it("should reject missing signedTxXdr", async () => { + it('should reject missing signedTxXdr', async () => { const response = await request(app) - .post("/api/loans/submit") + .post('/api/loans/submit') .set(bearer(TEST_BORROWER)) .send({}); expect(response.status).toBe(400); }); }); -describe("GET /api/loans/:loanId", () => { - it("should return loan details for the authenticated borrower", async () => { +describe('GET /api/loans/:loanId', () => { + it('should return loan details for the authenticated borrower', async () => { mockedQuery .mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }] }) // address check .mockResolvedValueOnce({ rows: [ { - event_type: "LoanRequested", - amount: "1000", + event_type: 'LoanRequested', + amount: '1000', ledger: 10, - ledger_closed_at: "2025-01-01T00:00:00.000Z", - tx_hash: "request-tx", + ledger_closed_at: '2025-01-01T00:00:00.000Z', + tx_hash: 'request-tx', interest_rate_bps: null, term_ledgers: null, }, { - event_type: "LoanApproved", + event_type: 'LoanApproved', amount: null, ledger: 20, - ledger_closed_at: "2025-01-02T00:00:00.000Z", - tx_hash: "approve-tx", + ledger_closed_at: '2025-01-02T00:00:00.000Z', + tx_hash: 'approve-tx', interest_rate_bps: 1200, term_ledgers: 17280, }, @@ -328,56 +324,50 @@ describe("GET /api/loans/:loanId", () => { .mockResolvedValueOnce({ rows: [{ last_indexed_ledger: 25 }] }) // getLatestLedger .mockResolvedValueOnce({ rows: [] }); // loan_disputes (no open disputes) - const response = await request(app) - .get("/api/loans/123") - .set(bearer(TEST_BORROWER)); + const response = await request(app).get('/api/loans/123').set(bearer(TEST_BORROWER)); expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.loanId).toBe("123"); + expect(response.body.loanId).toBe('123'); expect(response.body.summary.principal).toBe(1000); }); - it("should return 403 when the loan belongs to another borrower", async () => { + it('should return 403 when the loan belongs to another borrower', async () => { mockedQuery.mockResolvedValueOnce({ - rows: [{ address: "other-wallet" }], + rows: [{ address: 'other-wallet' }], }); - const response = await request(app) - .get("/api/loans/123") - .set(bearer(TEST_BORROWER)); + const response = await request(app).get('/api/loans/123').set(bearer(TEST_BORROWER)); expect(response.status).toBe(403); }); - it("should return 404 when the loan does not exist", async () => { + it('should return 404 when the loan does not exist', async () => { mockedQuery.mockResolvedValueOnce({ rows: [], }); - const response = await request(app) - .get("/api/loans/123") - .set(bearer(TEST_BORROWER)); + const response = await request(app).get('/api/loans/123').set(bearer(TEST_BORROWER)); expect(response.status).toBe(404); }); }); -describe("GET /api/loans/:loanId/amortization-schedule", () => { - it("should return amortization schedule for an approved loan", async () => { +describe('GET /api/loans/:loanId/amortization-schedule', () => { + it('should return amortization schedule for an approved loan', async () => { mockedQuery .mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }] }) .mockResolvedValueOnce({ rows: [ { - event_type: "LoanRequested", - amount: "1000", - ledger_closed_at: "2025-01-01T00:00:00.000Z", + event_type: 'LoanRequested', + amount: '1000', + ledger_closed_at: '2025-01-01T00:00:00.000Z', }, { - event_type: "LoanApproved", + event_type: 'LoanApproved', amount: null, - ledger_closed_at: "2025-01-01T00:00:00.000Z", + ledger_closed_at: '2025-01-01T00:00:00.000Z', interest_rate_bps: 1200, term_ledgers: 518400, }, @@ -385,7 +375,7 @@ describe("GET /api/loans/:loanId/amortization-schedule", () => { }); const response = await request(app) - .get("/api/loans/123/amortization-schedule") + .get('/api/loans/123/amortization-schedule') .set(bearer(TEST_BORROWER)); expect(response.status).toBe(200); @@ -399,38 +389,38 @@ describe("GET /api/loans/:loanId/amortization-schedule", () => { expect(response.body.amortization.schedule.length).toBeGreaterThan(0); }); - it("should return 404 when loan is not fully approved", async () => { + it('should return 404 when loan is not fully approved', async () => { mockedQuery .mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }] }) .mockResolvedValueOnce({ rows: [ { - event_type: "LoanRequested", - amount: "1000", - ledger_closed_at: "2025-01-01T00:00:00.000Z", + event_type: 'LoanRequested', + amount: '1000', + ledger_closed_at: '2025-01-01T00:00:00.000Z', }, ], }); const response = await request(app) - .get("/api/loans/123/amortization-schedule") + .get('/api/loans/123/amortization-schedule') .set(bearer(TEST_BORROWER)); expect(response.status).toBe(404); }); }); -describe("POST /api/loans/amortization-preview", () => { +describe('POST /api/loans/amortization-preview', () => { const originalMinScore = process.env.LOAN_MIN_SCORE; const originalMaxAmount = process.env.LOAN_MAX_AMOUNT; const originalInterest = process.env.LOAN_INTEREST_RATE_PERCENT; const originalThreshold = process.env.CREDIT_SCORE_THRESHOLD; beforeEach(() => { - process.env.LOAN_MIN_SCORE = "500"; - process.env.LOAN_MAX_AMOUNT = "50000"; - process.env.LOAN_INTEREST_RATE_PERCENT = "12"; - process.env.CREDIT_SCORE_THRESHOLD = "600"; + process.env.LOAN_MIN_SCORE = '500'; + process.env.LOAN_MAX_AMOUNT = '50000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '12'; + process.env.CREDIT_SCORE_THRESHOLD = '600'; }); afterEach(() => { @@ -459,10 +449,10 @@ describe("POST /api/loans/amortization-preview", () => { } }); - it("should return amortization preview for valid terms", async () => { + it('should return amortization preview for valid terms', async () => { const response = await request(app) - .post("/api/loans/amortization-preview") - .set(bearer("GABC123")) + .post('/api/loans/amortization-preview') + .set(bearer('GABC123')) .send({ amount: 1000, termDays: 60 }); expect(response.status).toBe(200); @@ -476,10 +466,10 @@ describe("POST /api/loans/amortization-preview", () => { expect(response.body.amortization.schedule.length).toBe(2); }); - it("should reject invalid termDays", async () => { + it('should reject invalid termDays', async () => { const response = await request(app) - .post("/api/loans/amortization-preview") - .set(bearer("GABC123")) + .post('/api/loans/amortization-preview') + .set(bearer('GABC123')) .send({ amount: 1000, termDays: 45 }); expect(response.status).toBe(400); @@ -489,56 +479,56 @@ describe("POST /api/loans/amortization-preview", () => { // --------------------------------------------------------------------------- // POST /api/loans/:loanId/repay // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/repay", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/:loanId/repay', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/1/repay") + .post('/api/loans/1/repay') .send({ amount: 500, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(401); }); - it("should return unsigned XDR for valid repayment", async () => { + it('should return unsigned XDR for valid repayment', async () => { // requireLoanBorrowerAccess check mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); mockBuildRepayTx.mockResolvedValueOnce({ - unsignedTxXdr: "BBBB...repay-xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'BBBB...repay-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/1/repay") + .post('/api/loans/1/repay') .set(bearer(TEST_BORROWER)) .send({ amount: 500, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.loanId).toBe(1); - expect(response.body.unsignedTxXdr).toBe("BBBB...repay-xdr"); + expect(response.body.unsignedTxXdr).toBe('BBBB...repay-xdr'); }); - it("should return 403 when loan does not belong to user", async () => { + it('should return 403 when loan does not belong to user', async () => { mockedQuery.mockResolvedValueOnce({ - rows: [{ address: "other-wallet" }], + rows: [{ address: 'other-wallet' }], }); const response = await request(app) - .post("/api/loans/1/repay") + .post('/api/loans/1/repay') .set(bearer(TEST_BORROWER)) .send({ amount: 500, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(403); }); - it("should reject missing amount", async () => { + it('should reject missing amount', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); const response = await request(app) - .post("/api/loans/1/repay") + .post('/api/loans/1/repay') .set(bearer(TEST_BORROWER)) .send({ borrowerPublicKey: TEST_BORROWER }); @@ -559,68 +549,68 @@ describe("POST /api/loans/:loanId/repay", () => { // --------------------------------------------------------------------------- // POST /api/loans/:loanId/submit // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/submit", () => { - it("should submit a signed repayment transaction", async () => { +describe('POST /api/loans/:loanId/submit', () => { + it('should submit a signed repayment transaction', async () => { // requireLoanBorrowerAccess mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); mockSubmitSignedTx.mockResolvedValueOnce({ - txHash: "repay-hash-456", - status: "SUCCESS", + txHash: 'repay-hash-456', + status: 'SUCCESS', }); const response = await request(app) - .post("/api/loans/1/submit") + .post('/api/loans/1/submit') .set(bearer(TEST_BORROWER)) - .send({ signedTxXdr: "c2lnbmVkLXJlcGF5LXhkcg==" }); + .send({ signedTxXdr: 'c2lnbmVkLXJlcGF5LXhkcg==' }); expect(response.status).toBe(200); - expect(response.body.txHash).toBe("repay-hash-456"); - expect(response.body.status).toBe("SUCCESS"); + expect(response.body.txHash).toBe('repay-hash-456'); + expect(response.body.status).toBe('SUCCESS'); }); }); // --------------------------------------------------------------------------- // POST /api/loans/:loanId/build-deposit-collateral // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/build-deposit-collateral", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/:loanId/build-deposit-collateral', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/1/build-deposit-collateral") + .post('/api/loans/1/build-deposit-collateral') .send({ amount: 500, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(401); }); - it("should return unsigned XDR for valid deposit collateral", async () => { + it('should return unsigned XDR for valid deposit collateral', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); mockBuildDepositCollateralTx.mockResolvedValueOnce({ - unsignedTxXdr: "CCCC...deposit-collateral-xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'CCCC...deposit-collateral-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/1/build-deposit-collateral") + .post('/api/loans/1/build-deposit-collateral') .set(bearer(TEST_BORROWER)) .send({ amount: 500, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.loanId).toBe(1); - expect(response.body.unsignedTxXdr).toBe("CCCC...deposit-collateral-xdr"); + expect(response.body.unsignedTxXdr).toBe('CCCC...deposit-collateral-xdr'); }); - it("should return 403 when loan does not belong to user", async () => { + it('should return 403 when loan does not belong to user', async () => { mockedQuery.mockResolvedValueOnce({ - rows: [{ address: "other-wallet" }], + rows: [{ address: 'other-wallet' }], }); const response = await request(app) - .post("/api/loans/1/build-deposit-collateral") + .post('/api/loans/1/build-deposit-collateral') .set(bearer(TEST_BORROWER)) .send({ amount: 500, borrowerPublicKey: TEST_BORROWER }); @@ -631,42 +621,42 @@ describe("POST /api/loans/:loanId/build-deposit-collateral", () => { // --------------------------------------------------------------------------- // POST /api/loans/:loanId/build-release-collateral // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/build-release-collateral", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/:loanId/build-release-collateral', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/1/build-release-collateral") + .post('/api/loans/1/build-release-collateral') .send({ borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(401); }); - it("should return unsigned XDR for valid release collateral", async () => { + it('should return unsigned XDR for valid release collateral', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); mockBuildReleaseCollateralTx.mockResolvedValueOnce({ - unsignedTxXdr: "DDDD...release-collateral-xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'DDDD...release-collateral-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/1/build-release-collateral") + .post('/api/loans/1/build-release-collateral') .set(bearer(TEST_BORROWER)) .send({ borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.loanId).toBe(1); - expect(response.body.unsignedTxXdr).toBe("DDDD...release-collateral-xdr"); + expect(response.body.unsignedTxXdr).toBe('DDDD...release-collateral-xdr'); }); - it("should return 403 when loan does not belong to user", async () => { + it('should return 403 when loan does not belong to user', async () => { mockedQuery.mockResolvedValueOnce({ - rows: [{ address: "other-wallet" }], + rows: [{ address: 'other-wallet' }], }); const response = await request(app) - .post("/api/loans/1/build-release-collateral") + .post('/api/loans/1/build-release-collateral') .set(bearer(TEST_BORROWER)) .send({ borrowerPublicKey: TEST_BORROWER }); @@ -677,30 +667,28 @@ describe("POST /api/loans/:loanId/build-release-collateral", () => { // --------------------------------------------------------------------------- // POST /api/loans/:loanId/build-refinance // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/build-refinance", () => { - it("should reject unauthenticated requests", async () => { - const response = await request(app) - .post("/api/loans/1/build-refinance") - .send({ - newAmount: 2000, - newTerm: 34560, - borrowerPublicKey: TEST_BORROWER, - }); +describe('POST /api/loans/:loanId/build-refinance', () => { + it('should reject unauthenticated requests', async () => { + const response = await request(app).post('/api/loans/1/build-refinance').send({ + newAmount: 2000, + newTerm: 34560, + borrowerPublicKey: TEST_BORROWER, + }); expect(response.status).toBe(401); }); - it("should return unsigned XDR for valid refinance", async () => { + it('should return unsigned XDR for valid refinance', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); mockBuildRefinanceLoanTx.mockResolvedValueOnce({ - unsignedTxXdr: "EEEE...refinance-xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'EEEE...refinance-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/1/build-refinance") + .post('/api/loans/1/build-refinance') .set(bearer(TEST_BORROWER)) .send({ newAmount: 2000, @@ -711,16 +699,16 @@ describe("POST /api/loans/:loanId/build-refinance", () => { expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.loanId).toBe(1); - expect(response.body.unsignedTxXdr).toBe("EEEE...refinance-xdr"); + expect(response.body.unsignedTxXdr).toBe('EEEE...refinance-xdr'); }); - it("should return 403 when loan does not belong to user", async () => { + it('should return 403 when loan does not belong to user', async () => { mockedQuery.mockResolvedValueOnce({ - rows: [{ address: "other-wallet" }], + rows: [{ address: 'other-wallet' }], }); const response = await request(app) - .post("/api/loans/1/build-refinance") + .post('/api/loans/1/build-refinance') .set(bearer(TEST_BORROWER)) .send({ newAmount: 2000, @@ -731,13 +719,13 @@ describe("POST /api/loans/:loanId/build-refinance", () => { expect(response.status).toBe(403); }); - it("should reject missing newTerm", async () => { + it('should reject missing newTerm', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); const response = await request(app) - .post("/api/loans/1/build-refinance") + .post('/api/loans/1/build-refinance') .set(bearer(TEST_BORROWER)) .send({ newAmount: 2000, borrowerPublicKey: TEST_BORROWER }); @@ -748,55 +736,55 @@ describe("POST /api/loans/:loanId/build-refinance", () => { // --------------------------------------------------------------------------- // POST /api/loans/:loanId/build-extend // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/build-extend", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/:loanId/build-extend', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/1/build-extend") + .post('/api/loans/1/build-extend') .send({ extraLedgers: 8640, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(401); }); - it("should return unsigned XDR for valid extend", async () => { + it('should return unsigned XDR for valid extend', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); mockBuildExtendLoanTx.mockResolvedValueOnce({ - unsignedTxXdr: "FFFF...extend-xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'FFFF...extend-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/1/build-extend") + .post('/api/loans/1/build-extend') .set(bearer(TEST_BORROWER)) .send({ extraLedgers: 8640, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.loanId).toBe(1); - expect(response.body.unsignedTxXdr).toBe("FFFF...extend-xdr"); + expect(response.body.unsignedTxXdr).toBe('FFFF...extend-xdr'); }); - it("should return 403 when loan does not belong to user", async () => { + it('should return 403 when loan does not belong to user', async () => { mockedQuery.mockResolvedValueOnce({ - rows: [{ address: "other-wallet" }], + rows: [{ address: 'other-wallet' }], }); const response = await request(app) - .post("/api/loans/1/build-extend") + .post('/api/loans/1/build-extend') .set(bearer(TEST_BORROWER)) .send({ extraLedgers: 8640, borrowerPublicKey: TEST_BORROWER }); expect(response.status).toBe(403); }); - it("should reject missing extraLedgers", async () => { + it('should reject missing extraLedgers', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }], }); const response = await request(app) - .post("/api/loans/1/build-extend") + .post('/api/loans/1/build-extend') .set(bearer(TEST_BORROWER)) .send({ borrowerPublicKey: TEST_BORROWER }); @@ -807,54 +795,52 @@ describe("POST /api/loans/:loanId/build-extend", () => { // --------------------------------------------------------------------------- // POST /api/loans/:loanId/liquidate/build // --------------------------------------------------------------------------- -describe("POST /api/loans/:loanId/liquidate/build", () => { - it("should reject unauthenticated requests", async () => { +describe('POST /api/loans/:loanId/liquidate/build', () => { + it('should reject unauthenticated requests', async () => { const response = await request(app) - .post("/api/loans/1/liquidate/build") + .post('/api/loans/1/liquidate/build') .send({ liquidatorPublicKey: TEST_BORROWER }); expect(response.status).toBe(401); }); - it("should return unsigned XDR for valid liquidation build request", async () => { + it('should return unsigned XDR for valid liquidation build request', async () => { mockBuildLiquidateTx.mockResolvedValueOnce({ - unsignedTxXdr: "GGGG...liquidate-xdr", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'GGGG...liquidate-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', }); const response = await request(app) - .post("/api/loans/1/liquidate/build") + .post('/api/loans/1/liquidate/build') .set(bearer(TEST_BORROWER)) .send({ liquidatorPublicKey: TEST_BORROWER }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.loanId).toBe(1); - expect(response.body.unsignedTxXdr).toBe("GGGG...liquidate-xdr"); + expect(response.body.unsignedTxXdr).toBe('GGGG...liquidate-xdr'); expect(mockBuildLiquidateTx).toHaveBeenCalledWith(TEST_BORROWER, 1); }); - it("should reject requests where liquidatorPublicKey does not match JWT", async () => { + it('should reject requests where liquidatorPublicKey does not match JWT', async () => { const otherWallet = Keypair.random().publicKey(); const response = await request(app) - .post("/api/loans/1/liquidate/build") + .post('/api/loans/1/liquidate/build') .set(bearer(TEST_BORROWER)) .send({ liquidatorPublicKey: otherWallet }); expect(response.status).toBe(403); }); - it("should propagate non-liquidatable build failures", async () => { - mockBuildLiquidateTx.mockRejectedValueOnce( - new Error("Loan is not liquidatable"), - ); + it('should propagate non-liquidatable build failures', async () => { + mockBuildLiquidateTx.mockRejectedValueOnce(new Error('Loan is not liquidatable')); const response = await request(app) - .post("/api/loans/1/liquidate/build") + .post('/api/loans/1/liquidate/build') .set(bearer(TEST_BORROWER)) .send({ liquidatorPublicKey: TEST_BORROWER }); expect(response.status).toBe(500); expect(response.body.success).toBe(false); - expect(response.body.message).toBe("Internal server error"); + expect(response.body.message).toBe('Internal server error'); }); }); diff --git a/backend/src/__tests__/loanFilters.test.ts b/backend/src/__tests__/loanFilters.test.ts index c93d4af6..6f051922 100644 --- a/backend/src/__tests__/loanFilters.test.ts +++ b/backend/src/__tests__/loanFilters.test.ts @@ -1,14 +1,7 @@ -import { - jest, - describe, - it, - expect, - beforeEach, - afterAll, -} from "@jest/globals"; -import request from "supertest"; -import { Keypair } from "@stellar/stellar-sdk"; -import { generateJwtToken } from "../services/authService.js"; +import { jest, describe, it, expect, beforeEach, afterAll } from '@jest/globals'; +import request from 'supertest'; +import { Keypair } from '@stellar/stellar-sdk'; +import { generateJwtToken } from '../services/authService.js'; /** * Tests for status + date-range filters on GET /api/loans/borrower/:borrower @@ -21,8 +14,8 @@ import { generateJwtToken } from "../services/authService.js"; */ const TEST_BORROWER = Keypair.random().publicKey(); -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; -process.env.INTERNAL_API_KEY = "test-key"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; +process.env.INTERNAL_API_KEY = 'test-key'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; const mockQuery: jest.MockedFunction< @@ -32,31 +25,27 @@ const mockQuery: jest.MockedFunction< const mockRelease = jest.fn(); const mockClient = { query: mockQuery, release: mockRelease }; -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, - getClient: jest - .fn<() => Promise>() - .mockResolvedValue(mockClient), + getClient: jest.fn<() => Promise>().mockResolvedValue(mockClient), closePool: jest.fn(), withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), - invalidatePattern: jest - .fn<() => Promise>() - .mockResolvedValue(undefined), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), + invalidatePattern: jest.fn<() => Promise>().mockResolvedValue(undefined), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), healthCheck: jest .fn<() => Promise<{ connected: boolean; latestLedger: number }>>() .mockResolvedValue({ @@ -66,7 +55,7 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ }, })); -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, @@ -77,22 +66,22 @@ function makeLoanRow(overrides: Record = {}) { return { loan_id: 1, address: TEST_BORROWER, - principal: "1000", - approved_at: "2024-01-15T00:00:00.000Z", - approved_ledger: "500", - rate_bps: "1200", - term_ledgers: "17280", - total_repaid: "0", + principal: '1000', + approved_at: '2024-01-15T00:00:00.000Z', + approved_ledger: '500', + rate_bps: '1200', + term_ledgers: '17280', + total_repaid: '0', is_defaulted: 0, - effective_rate_bps: "1200", - effective_term_ledgers: "17280", - effective_approved_ledger: "500", - accrued_interest: "5", - total_owed: "1005", - next_payment_deadline: "2024-02-15T00:00:00.000Z", - status: "active", + effective_rate_bps: '1200', + effective_term_ledgers: '17280', + effective_approved_ledger: '500', + accrued_interest: '5', + total_owed: '1005', + next_payment_deadline: '2024-02-15T00:00:00.000Z', + status: 'active', borrower: TEST_BORROWER, - full_count: "1", + full_count: '1', ...overrides, }; } @@ -101,7 +90,7 @@ beforeEach(() => { mockQuery.mockReset(); // Default: indexer_state query returns ledger 1000 mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; @@ -113,13 +102,13 @@ afterAll(() => { delete process.env.INTERNAL_API_KEY; }); -describe("GET /api/loans/borrower/:borrower – filters", () => { - describe("status filter", () => { - it("returns active loans when status=active", async () => { - const activeRow = makeLoanRow({ status: "active" }); +describe('GET /api/loans/borrower/:borrower – filters', () => { + describe('status filter', () => { + it('returns active loans when status=active', async () => { + const activeRow = makeLoanRow({ status: 'active' }); mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: [activeRow], rowCount: 1 }; @@ -132,10 +121,10 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.data.loans).toHaveLength(1); - expect(res.body.data.loans[0].status).toBe("active"); + expect(res.body.data.loans[0].status).toBe('active'); }); - it("returns 400 for an invalid status value", async () => { + it('returns 400 for an invalid status value', async () => { const res = await request(app) .get(`/api/loans/borrower/${TEST_BORROWER}?status=invalid_status`) .set(bearer(TEST_BORROWER)); @@ -143,9 +132,9 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { expect(res.status).toBe(400); }); - it("returns empty array when status=repaid and no repaid loans exist", async () => { + it('returns empty array when status=repaid and no repaid loans exist', async () => { mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; @@ -160,28 +149,26 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { }); }); - describe("date-range filter (from/to)", () => { - it("forwards from/to params and returns matching loans", async () => { - const row = makeLoanRow({ approved_at: "2024-03-01T00:00:00.000Z" }); + describe('date-range filter (from/to)', () => { + it('forwards from/to params and returns matching loans', async () => { + const row = makeLoanRow({ approved_at: '2024-03-01T00:00:00.000Z' }); mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: [row], rowCount: 1 }; }); const res = await request(app) - .get( - `/api/loans/borrower/${TEST_BORROWER}?from=2024-01-01&to=2024-12-31`, - ) + .get(`/api/loans/borrower/${TEST_BORROWER}?from=2024-01-01&to=2024-12-31`) .set(bearer(TEST_BORROWER)); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); - it("returns 400 for an invalid date in from param", async () => { + it('returns 400 for an invalid date in from param', async () => { const res = await request(app) .get(`/api/loans/borrower/${TEST_BORROWER}?from=not-a-date`) .set(bearer(TEST_BORROWER)); @@ -189,18 +176,16 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { expect(res.status).toBe(400); }); - it("returns empty array when date range matches no loans", async () => { + it('returns empty array when date range matches no loans', async () => { mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; }); const res = await request(app) - .get( - `/api/loans/borrower/${TEST_BORROWER}?from=2020-01-01&to=2020-12-31`, - ) + .get(`/api/loans/borrower/${TEST_BORROWER}?from=2020-01-01&to=2020-12-31`) .set(bearer(TEST_BORROWER)); expect(res.status).toBe(200); @@ -208,24 +193,22 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { }); }); - describe("combined status + date-range filter", () => { - it("returns loans matching both status and date range", async () => { + describe('combined status + date-range filter', () => { + it('returns loans matching both status and date range', async () => { const row = makeLoanRow({ - status: "repaid", - approved_at: "2024-06-01T00:00:00.000Z", + status: 'repaid', + approved_at: '2024-06-01T00:00:00.000Z', }); mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: [row], rowCount: 1 }; }); const res = await request(app) - .get( - `/api/loans/borrower/${TEST_BORROWER}?status=repaid&from=2024-01-01&to=2024-12-31`, - ) + .get(`/api/loans/borrower/${TEST_BORROWER}?status=repaid&from=2024-01-01&to=2024-12-31`) .set(bearer(TEST_BORROWER)); expect(res.status).toBe(200); @@ -233,14 +216,14 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { }); }); - describe("pagination with filters", () => { - it("accepts valid limit parameter", async () => { + describe('pagination with filters', () => { + it('accepts valid limit parameter', async () => { const rows = Array.from({ length: 5 }, (_, i) => - makeLoanRow({ loan_id: i + 1, full_count: "5" }), + makeLoanRow({ loan_id: i + 1, full_count: '5' }), ); mockQuery.mockImplementation(async (sql: unknown) => { - if (typeof sql === "string" && sql.includes("indexer_state")) { + if (typeof sql === 'string' && sql.includes('indexer_state')) { return { rows: [{ last_indexed_ledger: 1000 }], rowCount: 1 }; } return { rows: rows.slice(0, 3), rowCount: 3 }; @@ -253,7 +236,7 @@ describe("GET /api/loans/borrower/:borrower – filters", () => { expect(res.status).toBe(200); }); - it("returns 400 when limit exceeds maximum", async () => { + it('returns 400 when limit exceeds maximum', async () => { const res = await request(app) .get(`/api/loans/borrower/${TEST_BORROWER}?limit=999`) .set(bearer(TEST_BORROWER)); diff --git a/backend/src/__tests__/metrics.test.ts b/backend/src/__tests__/metrics.test.ts index f5306d93..5669df74 100644 --- a/backend/src/__tests__/metrics.test.ts +++ b/backend/src/__tests__/metrics.test.ts @@ -1,17 +1,17 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import request from 'supertest'; -process.env.INTERNAL_API_KEY = "test-metrics-key"; +process.env.INTERNAL_API_KEY = 'test-metrics-key'; const queryMock = jest.fn(async (sql: string) => { - if (sql.includes("FROM webhook_deliveries")) { + if (sql.includes('FROM webhook_deliveries')) { return { rows: [{ count: 7 }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; }); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: queryMock, }, @@ -20,15 +20,15 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), getScoreConfig: jest.fn(() => ({ repaymentDelta: 20, defaultPenalty: 50, @@ -36,53 +36,46 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ }, })); -const { default: app } = await import("../app.js"); -const { - recordIndexerLedgers, - recordScoreReconciliationRun, - refreshWebhookRetryQueueDepth, -} = await import("../middleware/metrics.js"); +const { default: app } = await import('../app.js'); +const { recordIndexerLedgers, recordScoreReconciliationRun, refreshWebhookRetryQueueDepth } = + await import('../middleware/metrics.js'); -describe("GET /metrics", () => { +describe('GET /metrics', () => { beforeEach(() => { queryMock.mockClear(); }); - it("requires the internal API key", async () => { - const response = await request(app).get("/metrics"); + it('requires the internal API key', async () => { + const response = await request(app).get('/metrics'); expect(response.status).toBe(401); }); - it("exposes default and custom Prometheus metrics", async () => { + it('exposes default and custom Prometheus metrics', async () => { recordIndexerLedgers(42, 50); - recordScoreReconciliationRun(new Date("2026-05-27T00:00:00.000Z")); + recordScoreReconciliationRun(new Date('2026-05-27T00:00:00.000Z')); await refreshWebhookRetryQueueDepth(); - const response = await request(app) - .get("/metrics") - .set("x-api-key", "test-metrics-key"); + const response = await request(app).get('/metrics').set('x-api-key', 'test-metrics-key'); expect(response.status).toBe(200); - expect(response.headers["content-type"]).toContain("text/plain"); - expect(response.text).toContain("process_cpu_user_seconds_total"); - expect(response.text).toContain("indexer_last_ledger 42"); - expect(response.text).toContain("indexer_chain_tip 50"); - expect(response.text).toContain("indexer_lag_ledgers 8"); - expect(response.text).toContain("webhook_retry_queue_depth 7"); - expect(response.text).toContain("score_reconciliation_last_run_timestamp"); - expect(response.text).toContain("http_request_duration_seconds_bucket"); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.text).toContain('process_cpu_user_seconds_total'); + expect(response.text).toContain('indexer_last_ledger 42'); + expect(response.text).toContain('indexer_chain_tip 50'); + expect(response.text).toContain('indexer_lag_ledgers 8'); + expect(response.text).toContain('webhook_retry_queue_depth 7'); + expect(response.text).toContain('score_reconciliation_last_run_timestamp'); + expect(response.text).toContain('http_request_duration_seconds_bucket'); }); - it("uses route templates rather than raw path values for HTTP labels", async () => { - await request(app).get("/api/loans/123"); + it('uses route templates rather than raw path values for HTTP labels', async () => { + await request(app).get('/api/loans/123'); - const response = await request(app) - .get("/metrics") - .set("x-api-key", "test-metrics-key"); + const response = await request(app).get('/metrics').set('x-api-key', 'test-metrics-key'); expect(response.status).toBe(200); - expect(response.text).not.toContain("/api/loans/123"); + expect(response.text).not.toContain('/api/loans/123'); expect(response.text).toMatch( /http_request_duration_seconds_bucket\{le="[^"]+",method="GET",route="(?:\/api\/loans)?\/:loanId",status_class="4xx"\}/, ); diff --git a/backend/src/__tests__/notificationDigest.test.ts b/backend/src/__tests__/notificationDigest.test.ts index 061b42c2..f312d1bd 100644 --- a/backend/src/__tests__/notificationDigest.test.ts +++ b/backend/src/__tests__/notificationDigest.test.ts @@ -1,14 +1,14 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -16,20 +16,19 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -await import("../db/connection.js"); -const { notificationService } = - await import("../services/notificationService.js"); +await import('../db/connection.js'); +const { notificationService } = await import('../services/notificationService.js'); -const userId = "GTESTUSER1111111111111111111111111111111111111111111111111"; +const userId = 'GTESTUSER1111111111111111111111111111111111111111111111111'; beforeEach(() => { mockQuery.mockReset(); @@ -40,105 +39,90 @@ afterAll(() => { delete process.env.JWT_SECRET; }); -describe("notification digest batching", () => { - it("batches repayment notifications with digest mode off", async () => { +describe('notification digest batching', () => { + it('batches repayment notifications with digest mode off', async () => { mockQuery.mockResolvedValue({ - rows: [{ digest_frequency: "off" }], + rows: [{ digest_frequency: 'off' }], }); const notifications = [ - { userId, message: "Loan 1 due", loanId: 1 }, - { userId, message: "Loan 2 due", loanId: 2 }, - { userId, message: "Loan 3 due", loanId: 3 }, + { userId, message: 'Loan 1 due', loanId: 1 }, + { userId, message: 'Loan 2 due', loanId: 2 }, + { userId, message: 'Loan 3 due', loanId: 3 }, ]; - const grouped = - await notificationService.batchRepaymentNotificationsForDigest( - notifications, - ); + const grouped = await notificationService.batchRepaymentNotificationsForDigest(notifications); expect(grouped.size).toBe(1); expect(grouped.has(`${userId}:immediate`)).toBe(true); expect(grouped.get(`${userId}:immediate`)).toHaveLength(3); }); - it("batches repayment notifications with daily digest mode", async () => { + it('batches repayment notifications with daily digest mode', async () => { mockQuery.mockResolvedValue({ - rows: [{ digest_frequency: "daily" }], + rows: [{ digest_frequency: 'daily' }], }); const notifications = [ - { userId, message: "Loan 1 due", loanId: 1 }, - { userId, message: "Loan 2 due", loanId: 2 }, + { userId, message: 'Loan 1 due', loanId: 1 }, + { userId, message: 'Loan 2 due', loanId: 2 }, ]; - const grouped = - await notificationService.batchRepaymentNotificationsForDigest( - notifications, - ); + const grouped = await notificationService.batchRepaymentNotificationsForDigest(notifications); expect(grouped.size).toBe(1); expect(grouped.has(`${userId}:daily`)).toBe(true); expect(grouped.get(`${userId}:daily`)).toHaveLength(2); }); - it("batches repayment notifications with weekly digest mode", async () => { + it('batches repayment notifications with weekly digest mode', async () => { mockQuery.mockResolvedValue({ - rows: [{ digest_frequency: "weekly" }], + rows: [{ digest_frequency: 'weekly' }], }); const notifications = [ - { userId, message: "Loan 1 due", loanId: 1 }, - { userId, message: "Loan 2 due", loanId: 2 }, - { userId, message: "Loan 3 due", loanId: 3 }, + { userId, message: 'Loan 1 due', loanId: 1 }, + { userId, message: 'Loan 2 due', loanId: 2 }, + { userId, message: 'Loan 3 due', loanId: 3 }, ]; - const grouped = - await notificationService.batchRepaymentNotificationsForDigest( - notifications, - ); + const grouped = await notificationService.batchRepaymentNotificationsForDigest(notifications); expect(grouped.size).toBe(1); expect(grouped.has(`${userId}:weekly`)).toBe(true); expect(grouped.get(`${userId}:weekly`)).toHaveLength(3); }); - it("handles multiple users with different digest preferences", async () => { - const user1 = "GUSER1111111111111111111111111111111111111111111111111111"; - const user2 = "GUSER2222222222222222222222222222222222222222222222222222"; + it('handles multiple users with different digest preferences', async () => { + const user1 = 'GUSER1111111111111111111111111111111111111111111111111111'; + const user2 = 'GUSER2222222222222222222222222222222222222222222222222222'; mockQuery - .mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] }) - .mockResolvedValueOnce({ rows: [{ digest_frequency: "weekly" }] }) - .mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] }); + .mockResolvedValueOnce({ rows: [{ digest_frequency: 'daily' }] }) + .mockResolvedValueOnce({ rows: [{ digest_frequency: 'weekly' }] }) + .mockResolvedValueOnce({ rows: [{ digest_frequency: 'daily' }] }); const notifications = [ - { userId: user1, message: "Loan 1 due", loanId: 1 }, - { userId: user2, message: "Loan 2 due", loanId: 2 }, - { userId: user1, message: "Loan 3 due", loanId: 3 }, + { userId: user1, message: 'Loan 1 due', loanId: 1 }, + { userId: user2, message: 'Loan 2 due', loanId: 2 }, + { userId: user1, message: 'Loan 3 due', loanId: 3 }, ]; - const grouped = - await notificationService.batchRepaymentNotificationsForDigest( - notifications, - ); + const grouped = await notificationService.batchRepaymentNotificationsForDigest(notifications); expect(grouped.size).toBe(2); expect(grouped.get(`${user1}:daily`)).toHaveLength(2); expect(grouped.get(`${user2}:weekly`)).toHaveLength(1); }); - it("defaults to off when digest_frequency is not set", async () => { + it('defaults to off when digest_frequency is not set', async () => { mockQuery.mockResolvedValue({ rows: [], }); - const notifications = [{ userId, message: "Loan 1 due", loanId: 1 }]; + const notifications = [{ userId, message: 'Loan 1 due', loanId: 1 }]; - const grouped = - await notificationService.batchRepaymentNotificationsForDigest( - notifications, - ); + const grouped = await notificationService.batchRepaymentNotificationsForDigest(notifications); expect(grouped.has(`${userId}:immediate`)).toBe(true); }); diff --git a/backend/src/__tests__/notificationFilters.test.ts b/backend/src/__tests__/notificationFilters.test.ts index 4d869f6b..c7d8514d 100644 --- a/backend/src/__tests__/notificationFilters.test.ts +++ b/backend/src/__tests__/notificationFilters.test.ts @@ -1,16 +1,16 @@ -import request from "supertest"; -import { jest } from "@jest/globals"; -import { generateJwtToken } from "../services/authService.js"; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { generateJwtToken } from '../services/authService.js'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -18,19 +18,19 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -await import("../db/connection.js"); -const { default: app } = await import("../app.js"); +await import('../db/connection.js'); +const { default: app } = await import('../app.js'); -const userId = "GTESTUSER1111111111111111111111111111111111111111111111111"; +const userId = 'GTESTUSER1111111111111111111111111111111111111111111111111'; const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, @@ -45,157 +45,149 @@ afterAll(() => { delete process.env.JWT_SECRET; }); -describe("notification filters", () => { - it("filters notifications by type", async () => { +describe('notification filters', () => { + it('filters notifications by type', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 1, user_id: userId, - type: "repayment_due", - title: "Repayment Due", - message: "Your repayment is due", + type: 'repayment_due', + title: 'Repayment Due', + message: 'Your repayment is due', loan_id: 1, read: false, - status: "unread", - created_at: new Date("2024-03-15"), + status: 'unread', + created_at: new Date('2024-03-15'), }, ], }); mockQuery.mockResolvedValueOnce({ - rows: [{ count: "1" }], + rows: [{ count: '1' }], }); const response = await request(app) - .get("/api/notifications?type=repayment_due") + .get('/api/notifications?type=repayment_due') .set(bearer(userId)); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.notifications).toHaveLength(1); - expect(response.body.data.notifications[0].type).toBe("repayment_due"); + expect(response.body.data.notifications[0].type).toBe('repayment_due'); }); - it("filters notifications by status", async () => { + it('filters notifications by status', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 2, user_id: userId, - type: "loan_approved", - title: "Loan Approved", - message: "Your loan has been approved", + type: 'loan_approved', + title: 'Loan Approved', + message: 'Your loan has been approved', loan_id: 2, read: true, - status: "read", - created_at: new Date("2024-03-10"), + status: 'read', + created_at: new Date('2024-03-10'), }, ], }); mockQuery.mockResolvedValueOnce({ - rows: [{ count: "1" }], + rows: [{ count: '1' }], }); - const response = await request(app) - .get("/api/notifications?status=read") - .set(bearer(userId)); + const response = await request(app).get('/api/notifications?status=read').set(bearer(userId)); expect(response.status).toBe(200); expect(response.body.data.notifications).toHaveLength(1); - expect(response.body.data.notifications[0].status).toBe("read"); + expect(response.body.data.notifications[0].status).toBe('read'); }); - it("filters notifications by date range", async () => { + it('filters notifications by date range', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 3, user_id: userId, - type: "score_changed", - title: "Score Changed", - message: "Your score has changed", + type: 'score_changed', + title: 'Score Changed', + message: 'Your score has changed', loan_id: null, read: false, - status: "unread", - created_at: new Date("2024-03-20"), + status: 'unread', + created_at: new Date('2024-03-20'), }, ], }); mockQuery.mockResolvedValueOnce({ - rows: [{ count: "1" }], + rows: [{ count: '1' }], }); const response = await request(app) - .get("/api/notifications?from=2024-03-01&to=2024-03-31") + .get('/api/notifications?from=2024-03-01&to=2024-03-31') .set(bearer(userId)); expect(response.status).toBe(200); expect(response.body.data.notifications).toHaveLength(1); }); - it("combines multiple filters", async () => { + it('combines multiple filters', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 4, user_id: userId, - type: "repayment_confirmed", - title: "Repayment Confirmed", - message: "Your repayment has been confirmed", + type: 'repayment_confirmed', + title: 'Repayment Confirmed', + message: 'Your repayment has been confirmed', loan_id: 3, read: true, - status: "read", - created_at: new Date("2024-03-25"), + status: 'read', + created_at: new Date('2024-03-25'), }, ], }); mockQuery.mockResolvedValueOnce({ - rows: [{ count: "1" }], + rows: [{ count: '1' }], }); const response = await request(app) - .get( - "/api/notifications?type=repayment_confirmed&status=read&from=2024-03-01&to=2024-03-31", - ) + .get('/api/notifications?type=repayment_confirmed&status=read&from=2024-03-01&to=2024-03-31') .set(bearer(userId)); expect(response.status).toBe(200); expect(response.body.data.notifications).toHaveLength(1); - expect(response.body.data.notifications[0].type).toBe( - "repayment_confirmed", - ); - expect(response.body.data.notifications[0].status).toBe("read"); + expect(response.body.data.notifications[0].type).toBe('repayment_confirmed'); + expect(response.body.data.notifications[0].status).toBe('read'); }); - it("rejects invalid date formats", async () => { + it('rejects invalid date formats', async () => { const response = await request(app) - .get("/api/notifications?from=invalid-date") + .get('/api/notifications?from=invalid-date') .set(bearer(userId)); expect(response.status).toBe(400); }); - it("respects limit parameter", async () => { + it('respects limit parameter', async () => { mockQuery.mockResolvedValueOnce({ rows: Array.from({ length: 10 }, (_, i) => ({ id: i + 1, user_id: userId, - type: "loan_approved", - title: "Loan Approved", - message: "Your loan has been approved", + type: 'loan_approved', + title: 'Loan Approved', + message: 'Your loan has been approved', loan_id: i + 1, read: false, - status: "unread", - created_at: new Date("2024-03-15"), + status: 'unread', + created_at: new Date('2024-03-15'), })), }); mockQuery.mockResolvedValueOnce({ - rows: [{ count: "10" }], + rows: [{ count: '10' }], }); - const response = await request(app) - .get("/api/notifications?limit=10") - .set(bearer(userId)); + const response = await request(app).get('/api/notifications?limit=10').set(bearer(userId)); expect(response.status).toBe(200); expect(response.body.data.notifications).toHaveLength(10); diff --git a/backend/src/__tests__/notificationPreferences.test.ts b/backend/src/__tests__/notificationPreferences.test.ts index b1ade701..944c187d 100644 --- a/backend/src/__tests__/notificationPreferences.test.ts +++ b/backend/src/__tests__/notificationPreferences.test.ts @@ -1,16 +1,16 @@ -import request from "supertest"; -import { jest } from "@jest/globals"; -import { generateJwtToken } from "../services/authService.js"; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { generateJwtToken } from '../services/authService.js'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -18,23 +18,23 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -await import("../db/connection.js"); -const { default: app } = await import("../app.js"); +await import('../db/connection.js'); +const { default: app } = await import('../app.js'); const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, @@ -49,15 +49,13 @@ afterAll(() => { delete process.env.JWT_SECRET; }); -describe("notification preferences endpoints", () => { - it("returns empty defaults when no profile row exists", async () => { +describe('notification preferences endpoints', () => { + it('returns empty defaults when no profile row exists', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const response = await request(app) - .get("/api/notifications/preferences") - .set( - bearer("GTESTUSER1111111111111111111111111111111111111111111111111"), - ); + .get('/api/notifications/preferences') + .set(bearer('GTESTUSER1111111111111111111111111111111111111111111111111')); expect(response.status).toBe(200); expect(response.body).toEqual({ @@ -68,18 +66,18 @@ describe("notification preferences endpoints", () => { }); }); - it("writes valid preferences and returns updated payload", async () => { + it('writes valid preferences and returns updated payload', async () => { mockQuery.mockResolvedValueOnce({ - rows: [{ email_enabled: true, sms_enabled: true, phone: "+14155552671" }], + rows: [{ email_enabled: true, sms_enabled: true, phone: '+14155552671' }], }); const response = await request(app) - .put("/api/notifications/preferences") - .set(bearer("GTESTUSER2222222222222222222222222222222222222222222222222")) + .put('/api/notifications/preferences') + .set(bearer('GTESTUSER2222222222222222222222222222222222222222222222222')) .send({ emailEnabled: true, smsEnabled: true, - phone: "+14155552671", + phone: '+14155552671', perTypeOverrides: { repayment_due: true }, }); @@ -87,33 +85,33 @@ describe("notification preferences endpoints", () => { expect(response.body).toEqual({ emailEnabled: true, smsEnabled: true, - phone: "+14155552671", + phone: '+14155552671', perTypeOverrides: {}, }); }); - it("returns 400 when phone format is invalid", async () => { + it('returns 400 when phone format is invalid', async () => { const response = await request(app) - .put("/api/notifications/preferences") - .set(bearer("GTESTUSER3333333333333333333333333333333333333333333333333")) + .put('/api/notifications/preferences') + .set(bearer('GTESTUSER3333333333333333333333333333333333333333333333333')) .send({ emailEnabled: true, smsEnabled: true, - phone: "invalid-phone", + phone: 'invalid-phone', perTypeOverrides: {}, }); expect(response.status).toBe(400); }); - it("returns 400 when sms is enabled without a phone number", async () => { + it('returns 400 when sms is enabled without a phone number', async () => { const response = await request(app) - .put("/api/notifications/preferences") - .set(bearer("GTESTUSER4444444444444444444444444444444444444444444444444")) + .put('/api/notifications/preferences') + .set(bearer('GTESTUSER4444444444444444444444444444444444444444444444444')) .send({ emailEnabled: true, smsEnabled: true, - phone: "", + phone: '', perTypeOverrides: {}, }); diff --git a/backend/src/__tests__/paginationFiltering.test.ts b/backend/src/__tests__/paginationFiltering.test.ts index dd9e88fe..21c0fe2c 100644 --- a/backend/src/__tests__/paginationFiltering.test.ts +++ b/backend/src/__tests__/paginationFiltering.test.ts @@ -1,24 +1,22 @@ -import request from "supertest"; -import { jest } from "@jest/globals"; -import { Keypair } from "@stellar/stellar-sdk"; -import { generateJwtToken } from "../services/authService.js"; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { Keypair } from '@stellar/stellar-sdk'; +import { generateJwtToken } from '../services/authService.js'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; -process.env.NODE_ENV = "test"; -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; -process.env.INTERNAL_API_KEY = "test-internal-key"; +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; +process.env.INTERNAL_API_KEY = 'test-internal-key'; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -const mockCacheGet = jest - .fn<() => Promise>() - .mockResolvedValue(null); +const mockCacheGet = jest.fn<() => Promise>().mockResolvedValue(null); const mockCacheSet = jest.fn<() => Promise>().mockResolvedValue(); -const mockCachePing = jest.fn<() => Promise>().mockResolvedValue("ok"); +const mockCachePing = jest.fn<() => Promise>().mockResolvedValue('ok'); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -26,7 +24,7 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: mockCacheGet, set: mockCacheSet, @@ -37,8 +35,8 @@ jest.unstable_mockModule("../services/cacheService.js", () => ({ }, })); -await import("../db/connection.js"); -const { default: app } = await import("../app.js"); +await import('../db/connection.js'); +const { default: app } = await import('../app.js'); const borrower = Keypair.random().publicKey(); @@ -58,10 +56,10 @@ afterAll(() => { delete process.env.INTERNAL_API_KEY; }); -describe("pagination and filtering", () => { - it("paginates and filters borrower loans with a consistent response envelope", async () => { +describe('pagination and filtering', () => { + it('paginates and filters borrower loans with a consistent response envelope', async () => { mockQuery.mockImplementation(async (text: string) => { - if (text.includes("last_indexed_ledger")) { + if (text.includes('last_indexed_ledger')) { return { rows: [{ last_indexed_ledger: 100 }] }; } return { @@ -69,17 +67,17 @@ describe("pagination and filtering", () => { { loan_id: 3, borrower, - principal: "250", - approved_at: "2024-02-20T00:00:00.000Z", + principal: '250', + approved_at: '2024-02-20T00:00:00.000Z', approved_ledger: 95, rate_bps: 1200, term_ledgers: 17280, - total_repaid: "0", - is_defaulted: "0", - accrued_interest: "0", - total_owed: "250", - next_payment_deadline: "2024-02-21T00:00:00.000Z", - status: "active", + total_repaid: '0', + is_defaulted: '0', + accrued_interest: '0', + total_owed: '250', + next_payment_deadline: '2024-02-21T00:00:00.000Z', + status: 'active', full_count: 2, }, ], @@ -108,38 +106,38 @@ describe("pagination and filtering", () => { expect(response.body.data.loans[0].principal).toBe(250); }); - it("applies event filters and returns page_info for borrower transaction history", async () => { + it('applies event filters and returns page_info for borrower transaction history', async () => { mockQuery .mockResolvedValueOnce({ rows: [ { id: 2, - event_id: "evt_2", - event_type: "LoanRepaid", + event_id: 'evt_2', + event_type: 'LoanRepaid', loan_id: 42, borrower, - amount: "250", + amount: '250', ledger: 200, - ledger_closed_at: "2024-02-15T12:00:00.000Z", - tx_hash: "tx_2", - created_at: "2024-02-15T12:00:00.000Z", + ledger_closed_at: '2024-02-15T12:00:00.000Z', + tx_hash: 'tx_2', + created_at: '2024-02-15T12:00:00.000Z', }, { id: 3, - event_id: "evt_3", - event_type: "LoanRepaid", + event_id: 'evt_3', + event_type: 'LoanRepaid', loan_id: 42, borrower, - amount: "300", + amount: '300', ledger: 201, - ledger_closed_at: "2024-02-15T12:01:00.000Z", - tx_hash: "tx_3", - created_at: "2024-02-15T12:01:00.000Z", + ledger_closed_at: '2024-02-15T12:01:00.000Z', + tx_hash: 'tx_3', + created_at: '2024-02-15T12:01:00.000Z', }, ], }) .mockResolvedValueOnce({ - rows: [{ count: "3" }], + rows: [{ count: '3' }], }); const response = await request(app) @@ -154,86 +152,80 @@ describe("pagination and filtering", () => { expect(response.body.page_info).toEqual({ limit: 1, count: 1, - next_cursor: "2", + next_cursor: '2', has_previous: true, has_next: true, }); expect(response.body.data.address).toBe(borrower); - expect(response.body.data.events[0].event_type).toBe("LoanRepaid"); + expect(response.body.data.events[0].event_type).toBe('LoanRepaid'); expect(mockQuery).toHaveBeenCalledTimes(2); - expect(mockQuery.mock.calls[0]?.[0]).toContain("event_type = $2"); - expect(mockQuery.mock.calls[0]?.[0]).toContain( - "CAST(amount AS NUMERIC) BETWEEN $3 AND $4", - ); - expect(mockQuery.mock.calls[0]?.[0]).toContain( - "ledger_closed_at BETWEEN $5 AND $6", - ); - expect(mockQuery.mock.calls[0]?.[0]).toContain("ORDER BY id ASC"); + expect(mockQuery.mock.calls[0]?.[0]).toContain('event_type = $2'); + expect(mockQuery.mock.calls[0]?.[0]).toContain('CAST(amount AS NUMERIC) BETWEEN $3 AND $4'); + expect(mockQuery.mock.calls[0]?.[0]).toContain('ledger_closed_at BETWEEN $5 AND $6'); + expect(mockQuery.mock.calls[0]?.[0]).toContain('ORDER BY id ASC'); }); - it("supports paginated recent events for admin dashboards", async () => { + it('supports paginated recent events for admin dashboards', async () => { mockQuery .mockResolvedValueOnce({ rows: [ { id: 2, - event_id: "evt_9", - event_type: "LoanDefaulted", + event_id: 'evt_9', + event_type: 'LoanDefaulted', loan_id: 77, borrower, - amount: "900", + amount: '900', ledger: 400, - ledger_closed_at: "2024-03-02T09:00:00.000Z", - tx_hash: "tx_9", - created_at: "2024-03-02T09:00:00.000Z", + ledger_closed_at: '2024-03-02T09:00:00.000Z', + tx_hash: 'tx_9', + created_at: '2024-03-02T09:00:00.000Z', }, { id: 3, - event_id: "evt_8", - event_type: "LoanDefaulted", + event_id: 'evt_8', + event_type: 'LoanDefaulted', loan_id: 76, borrower, - amount: "850", + amount: '850', ledger: 399, - ledger_closed_at: "2024-03-01T09:00:00.000Z", - tx_hash: "tx_8", - created_at: "2024-03-01T09:00:00.000Z", + ledger_closed_at: '2024-03-01T09:00:00.000Z', + tx_hash: 'tx_8', + created_at: '2024-03-01T09:00:00.000Z', }, { id: 4, - event_id: "evt_7", - event_type: "LoanDefaulted", + event_id: 'evt_7', + event_type: 'LoanDefaulted', loan_id: 75, borrower, - amount: "800", + amount: '800', ledger: 398, - ledger_closed_at: "2024-03-01T08:00:00.000Z", - tx_hash: "tx_7", - created_at: "2024-03-01T08:00:00.000Z", + ledger_closed_at: '2024-03-01T08:00:00.000Z', + tx_hash: 'tx_7', + created_at: '2024-03-01T08:00:00.000Z', }, ], }) .mockResolvedValueOnce({ - rows: [{ count: "5" }], + rows: [{ count: '5' }], }); const response = await request(app) - .get( - "/api/indexer/events/recent?status=LoanDefaulted&limit=2&cursor=100&sort=-amount", - ) - .set("x-api-key", process.env.INTERNAL_API_KEY as string); + .get('/api/indexer/events/recent?status=LoanDefaulted&limit=2&cursor=100&sort=-amount') + .set('x-api-key', process.env.INTERNAL_API_KEY as string); expect(response.status).toBe(200); expect(response.body.total_count).toBe(5); expect(response.body.page_info).toEqual({ limit: 2, count: 2, - next_cursor: "3", + next_cursor: '3', has_previous: true, has_next: true, }); expect(response.body.data.events).toHaveLength(2); - expect(mockQuery.mock.calls[0]?.[0]).toContain("ORDER BY id ASC"); + expect(mockQuery.mock.calls[0]?.[0]).toContain('ORDER BY id ASC'); }); }); diff --git a/backend/src/__tests__/poolController.asyncHandler.test.ts b/backend/src/__tests__/poolController.asyncHandler.test.ts index 25b7b8b4..78f1930b 100644 --- a/backend/src/__tests__/poolController.asyncHandler.test.ts +++ b/backend/src/__tests__/poolController.asyncHandler.test.ts @@ -1,5 +1,5 @@ -import { jest } from "@jest/globals"; -import type { NextFunction, Request, Response } from "express"; +import { jest } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; const mockQuery = jest.fn< @@ -9,16 +9,14 @@ const mockQuery = ) => Promise<{ rows: Record[]; rowCount: number }> >(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: mockQuery, getClient: jest.fn(), })); -const { getPoolStats, getDepositorPortfolio } = - await import("../controllers/poolController.js"); +const { getPoolStats, getDepositorPortfolio } = await import('../controllers/poolController.js'); -const flushAsync = async (): Promise => - new Promise((resolve) => setImmediate(resolve)); +const flushAsync = async (): Promise => new Promise((resolve) => setImmediate(resolve)); const createMockResponse = (): Response => ({ @@ -26,13 +24,13 @@ const createMockResponse = (): Response => json: jest.fn().mockReturnThis(), }) as unknown as Response; -describe("poolController asyncHandler wrapping", () => { +describe('poolController asyncHandler wrapping', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("forwards async errors from getPoolStats to next()", async () => { - const error = new Error("db failed"); + it('forwards async errors from getPoolStats to next()', async () => { + const error = new Error('db failed'); mockQuery.mockRejectedValue(error); const res = createMockResponse(); @@ -46,12 +44,12 @@ describe("poolController asyncHandler wrapping", () => { expect(res.json).not.toHaveBeenCalled(); }); - it("forwards async errors from getDepositorPortfolio to next()", async () => { - const error = new Error("db failed"); + it('forwards async errors from getDepositorPortfolio to next()', async () => { + const error = new Error('db failed'); mockQuery.mockRejectedValue(error); const req = { - params: { address: "GTESTADDRESS123" }, + params: { address: 'GTESTADDRESS123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); diff --git a/backend/src/__tests__/poolController.emergencyWithdraw.test.ts b/backend/src/__tests__/poolController.emergencyWithdraw.test.ts index ede9da2f..3f29cb03 100644 --- a/backend/src/__tests__/poolController.emergencyWithdraw.test.ts +++ b/backend/src/__tests__/poolController.emergencyWithdraw.test.ts @@ -1,5 +1,5 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import type { NextFunction, Request, Response } from "express"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; const mockBuildEmergencyWithdrawTx = jest.fn< @@ -10,34 +10,28 @@ const mockBuildEmergencyWithdrawTx = ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> >(); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { buildEmergencyWithdrawTx: mockBuildEmergencyWithdrawTx, getSharePrice: jest.fn(), }, })); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), getClient: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - get: jest - .fn<(...args: unknown[]) => Promise>() - .mockResolvedValue(null), - set: jest - .fn<(...args: unknown[]) => Promise>() - .mockResolvedValue(undefined), + get: jest.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null), + set: jest.fn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), }, })); -const { emergencyWithdrawFromPool } = - await import("../controllers/poolController.js"); +const { emergencyWithdrawFromPool } = await import('../controllers/poolController.js'); -const flushAsync = async (): Promise => - new Promise((resolve) => setImmediate(resolve)); +const flushAsync = async (): Promise => new Promise((resolve) => setImmediate(resolve)); const createMockResponse = (): Response => ({ @@ -45,24 +39,24 @@ const createMockResponse = (): Response => json: jest.fn().mockReturnThis(), }) as unknown as Response; -describe("emergencyWithdrawFromPool", () => { +describe('emergencyWithdrawFromPool', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("builds an unsigned emergency withdraw transaction", async () => { + it('builds an unsigned emergency withdraw transaction', async () => { mockBuildEmergencyWithdrawTx.mockResolvedValue({ - unsignedTxXdr: "AAAAAgAAAAtlbWVyZ2VuY3lfd2l0aGRyYXc=", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'AAAAAgAAAAtlbWVyZ2VuY3lfd2l0aGRyYXc=', + networkPassphrase: 'Test SDF Network ; September 2015', }); const req = { body: { - depositorPublicKey: "GDEPOSITOR123", - token: "GTOKEN456", + depositorPublicKey: 'GDEPOSITOR123', + token: 'GTOKEN456', shares: 500, }, - user: { publicKey: "GDEPOSITOR123" }, + user: { publicKey: 'GDEPOSITOR123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -70,26 +64,22 @@ describe("emergencyWithdrawFromPool", () => { emergencyWithdrawFromPool(req, res, next as unknown as NextFunction); await flushAsync(); - expect(mockBuildEmergencyWithdrawTx).toHaveBeenCalledWith( - "GDEPOSITOR123", - "GTOKEN456", - 500, - ); + expect(mockBuildEmergencyWithdrawTx).toHaveBeenCalledWith('GDEPOSITOR123', 'GTOKEN456', 500); expect(res.json).toHaveBeenCalledWith({ success: true, - unsignedTxXdr: "AAAAAgAAAAtlbWVyZ2VuY3lfd2l0aGRyYXc=", - networkPassphrase: "Test SDF Network ; September 2015", + unsignedTxXdr: 'AAAAAgAAAAtlbWVyZ2VuY3lfd2l0aGRyYXc=', + networkPassphrase: 'Test SDF Network ; September 2015', }); }); - it("rejects when depositorPublicKey does not match JWT", async () => { + it('rejects when depositorPublicKey does not match JWT', async () => { const req = { body: { - depositorPublicKey: "GWRONGKEY", - token: "GTOKEN456", + depositorPublicKey: 'GWRONGKEY', + token: 'GTOKEN456', shares: 500, }, - user: { publicKey: "GDEPOSITOR123" }, + user: { publicKey: 'GDEPOSITOR123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -98,15 +88,13 @@ describe("emergencyWithdrawFromPool", () => { await flushAsync(); expect(mockBuildEmergencyWithdrawTx).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ statusCode: 403 }), - ); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403 })); }); - it("rejects when required fields are missing", async () => { + it('rejects when required fields are missing', async () => { const req = { - body: { depositorPublicKey: "GDEPOSITOR123" }, - user: { publicKey: "GDEPOSITOR123" }, + body: { depositorPublicKey: 'GDEPOSITOR123' }, + user: { publicKey: 'GDEPOSITOR123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -115,8 +103,6 @@ describe("emergencyWithdrawFromPool", () => { await flushAsync(); expect(mockBuildEmergencyWithdrawTx).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ statusCode: 400 }), - ); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 400 })); }); }); diff --git a/backend/src/__tests__/poolController.sharePrice.test.ts b/backend/src/__tests__/poolController.sharePrice.test.ts index 4f996562..5a8d0db8 100644 --- a/backend/src/__tests__/poolController.sharePrice.test.ts +++ b/backend/src/__tests__/poolController.sharePrice.test.ts @@ -1,32 +1,31 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import type { NextFunction, Request, Response } from "express"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; const mockGetSharePrice = jest.fn<(tokenAddress?: string) => Promise>(); const mockCacheGet = jest.fn<() => Promise>(); const mockCacheSet = jest.fn<() => Promise>(); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { getSharePrice: mockGetSharePrice, }, })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: mockCacheGet, set: mockCacheSet, }, })); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), getClient: jest.fn(), })); -const { getPoolSharePrice } = await import("../controllers/poolController.js"); +const { getPoolSharePrice } = await import('../controllers/poolController.js'); -const flushAsync = async (): Promise => - new Promise((resolve) => setImmediate(resolve)); +const flushAsync = async (): Promise => new Promise((resolve) => setImmediate(resolve)); const createMockResponse = (): Response => ({ @@ -34,17 +33,17 @@ const createMockResponse = (): Response => json: jest.fn().mockReturnThis(), }) as unknown as Response; -describe("getPoolSharePrice", () => { +describe('getPoolSharePrice', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("returns share price from on-chain contract", async () => { + it('returns share price from on-chain contract', async () => { mockCacheGet.mockResolvedValue(null); mockGetSharePrice.mockResolvedValue(1_050_000); const req = { - params: { token: "GTOKEN123" }, + params: { token: 'GTOKEN123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -52,9 +51,9 @@ describe("getPoolSharePrice", () => { getPoolSharePrice(req, res, next as unknown as NextFunction); await flushAsync(); - expect(mockGetSharePrice).toHaveBeenCalledWith("GTOKEN123"); + expect(mockGetSharePrice).toHaveBeenCalledWith('GTOKEN123'); expect(mockCacheSet).toHaveBeenCalledWith( - expect.stringContaining("GTOKEN123"), + expect.stringContaining('GTOKEN123'), { sharePrice: 1_050_000, sharePriceRatio: 1.05 }, 30, ); @@ -65,14 +64,14 @@ describe("getPoolSharePrice", () => { }); }); - it("returns cached share price without calling contract", async () => { + it('returns cached share price without calling contract', async () => { mockCacheGet.mockResolvedValue({ sharePrice: 1_050_000, sharePriceRatio: 1.05, }); const req = { - params: { token: "GTOKEN123" }, + params: { token: 'GTOKEN123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -89,12 +88,12 @@ describe("getPoolSharePrice", () => { }); }); - it("returns share price ratio with correct human-readable value", async () => { + it('returns share price ratio with correct human-readable value', async () => { mockCacheGet.mockResolvedValue(null); mockGetSharePrice.mockResolvedValue(2_000_000); const req = { - params: { token: "GTOKEN123" }, + params: { token: 'GTOKEN123' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -102,12 +101,7 @@ describe("getPoolSharePrice", () => { getPoolSharePrice(req, res, next as unknown as NextFunction); await flushAsync(); - const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record< - string, - unknown - >; - expect((jsonCall.data as Record).sharePriceRatio).toBe( - 2.0, - ); + const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record; + expect((jsonCall.data as Record).sharePriceRatio).toBe(2.0); }); }); diff --git a/backend/src/__tests__/poolController.yieldHistory.test.ts b/backend/src/__tests__/poolController.yieldHistory.test.ts index dd86ba3d..25d2b061 100644 --- a/backend/src/__tests__/poolController.yieldHistory.test.ts +++ b/backend/src/__tests__/poolController.yieldHistory.test.ts @@ -1,18 +1,13 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import type { NextFunction, Request, Response } from "express"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; const mockBuildHistory = jest.fn< - ( - address: string, - token: string, - days: number, - currentSharePrice?: number, - ) => Promise + (address: string, token: string, days: number, currentSharePrice?: number) => Promise >(); const mockGetSharePrice = jest.fn<() => Promise>(); -jest.unstable_mockModule("../services/yieldHistoryService.js", () => ({ +jest.unstable_mockModule('../services/yieldHistoryService.js', () => ({ buildDepositorYieldHistory: mockBuildHistory, computeApy: (netYield: number, deposited: number, days: number) => deposited > 0 ? (netYield / deposited) * (365 / days) * 100 : 0, @@ -20,17 +15,15 @@ jest.unstable_mockModule("../services/yieldHistoryService.js", () => ({ days === 7 || days === 30 || days === 90 ? days : 30, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { getSharePrice: mockGetSharePrice, }, })); -const { getDepositorYieldHistory } = - await import("../controllers/poolController.js"); +const { getDepositorYieldHistory } = await import('../controllers/poolController.js'); -const flushAsync = async (): Promise => - new Promise((resolve) => setImmediate(resolve)); +const flushAsync = async (): Promise => new Promise((resolve) => setImmediate(resolve)); const createMockResponse = (): Response => ({ @@ -38,14 +31,14 @@ const createMockResponse = (): Response => json: jest.fn().mockReturnThis(), }) as unknown as Response; -describe("getDepositorYieldHistory", () => { +describe('getDepositorYieldHistory', () => { beforeEach(() => { jest.clearAllMocks(); - process.env.POOL_TOKEN_ADDRESS = "GTokenAddress"; + process.env.POOL_TOKEN_ADDRESS = 'GTokenAddress'; mockGetSharePrice.mockResolvedValue(1_050_000); mockBuildHistory.mockResolvedValue([ { - timestamp: "2026-05-01T00:00:00.000Z", + timestamp: '2026-05-01T00:00:00.000Z', depositedValue: 1000, currentValue: 1050, netYield: 50, @@ -53,10 +46,10 @@ describe("getDepositorYieldHistory", () => { ]); }); - it("returns mapped yield history payload", async () => { + it('returns mapped yield history payload', async () => { const req = { - params: { address: "GDepositor" }, - query: { days: "30" }, + params: { address: 'GDepositor' }, + query: { days: '30' }, } as unknown as Request; const res = createMockResponse(); const next = jest.fn<(err?: unknown) => void>(); @@ -64,12 +57,7 @@ describe("getDepositorYieldHistory", () => { getDepositorYieldHistory(req, res, next as unknown as NextFunction); await flushAsync(); - expect(mockBuildHistory).toHaveBeenCalledWith( - "GDepositor", - "GTokenAddress", - 30, - 1_050_000, - ); + expect(mockBuildHistory).toHaveBeenCalledWith('GDepositor', 'GTokenAddress', 30, 1_050_000); expect(res.json).toHaveBeenCalledWith({ success: true, data: [ diff --git a/backend/src/__tests__/remittanceService.test.ts b/backend/src/__tests__/remittanceService.test.ts index c76e4476..36b23de4 100644 --- a/backend/src/__tests__/remittanceService.test.ts +++ b/backend/src/__tests__/remittanceService.test.ts @@ -1,10 +1,5 @@ -import { jest } from "@jest/globals"; -import { - Account, - Keypair, - Networks, - TransactionBuilder, -} from "@stellar/stellar-sdk"; +import { jest } from '@jest/globals'; +import { Account, Keypair, Networks, TransactionBuilder } from '@stellar/stellar-sdk'; const mockWithTransaction = jest.fn<(...args: unknown[]) => Promise>(); const mockQuery = jest.fn< @@ -18,23 +13,23 @@ const mockQuery = jest.fn< >(); const mockGetAccount = jest.fn<() => Promise>(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: mockQuery, default: { query: mockQuery, connect: jest.fn(), end: jest.fn() }, })); -jest.unstable_mockModule("../db/transaction.js", () => ({ +jest.unstable_mockModule('../db/transaction.js', () => ({ withTransaction: mockWithTransaction, })); -jest.unstable_mockModule("../config/stellar.js", () => ({ +jest.unstable_mockModule('../config/stellar.js', () => ({ getStellarNetworkPassphrase: () => Networks.TESTNET, createSorobanRpcServer: () => ({ getAccount: mockGetAccount, }), })); -const { remittanceService } = await import("../services/remittanceService.js"); +const { remittanceService } = await import('../services/remittanceService.js'); const USDC_ISSUER = Keypair.random().publicKey(); const SENDER = Keypair.random().publicKey(); @@ -46,21 +41,21 @@ function mockRemittanceInsert() { query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }>; }) => Promise; const now = new Date(); - let xdrValue = ""; + let xdrValue = ''; const result = await callback({ query: async (_sql: string, queryParams: unknown[]) => { xdrValue = queryParams[8] as string; return { rows: [ { - id: "remit-1", + id: 'remit-1', sender_id: SENDER, recipient_address: RECIPIENT, - amount: "25", + amount: '25', from_currency: queryParams[4], to_currency: queryParams[5], memo: queryParams[6], - status: "pending", + status: 'pending', transaction_hash: null, xdr: xdrValue, created_at: now, @@ -74,51 +69,51 @@ function mockRemittanceInsert() { }); } -describe("remittanceService.createRemittance", () => { +describe('remittanceService.createRemittance', () => { beforeEach(() => { jest.clearAllMocks(); delete process.env.STELLAR_USDC_ISSUER; delete process.env.STELLAR_EURC_ISSUER; delete process.env.STELLAR_PHP_ISSUER; - mockGetAccount.mockResolvedValue(new Account(SENDER, "12345")); + mockGetAccount.mockResolvedValue(new Account(SENDER, '12345')); mockRemittanceInsert(); }); - it("rejects unsupported source currencies", async () => { + it('rejects unsupported source currencies', async () => { await expect( remittanceService.createRemittance({ recipientAddress: RECIPIENT, amount: 25, - fromCurrency: "DOGE", - toCurrency: "USDC", - memo: "test", + fromCurrency: 'DOGE', + toCurrency: 'USDC', + memo: 'test', senderAddress: SENDER, }), - ).rejects.toThrow("Unsupported currency: DOGE"); + ).rejects.toThrow('Unsupported currency: DOGE'); }); - it("rejects token currencies when issuer is not configured", async () => { + it('rejects token currencies when issuer is not configured', async () => { await expect( remittanceService.createRemittance({ recipientAddress: RECIPIENT, amount: 25, - fromCurrency: "USDC", - toCurrency: "USDC", - memo: "test", + fromCurrency: 'USDC', + toCurrency: 'USDC', + memo: 'test', senderAddress: SENDER, }), - ).rejects.toThrow("Unsupported currency: USDC"); + ).rejects.toThrow('Unsupported currency: USDC'); }); - it("builds token transfer XDR for configured USDC remittances", async () => { + it('builds token transfer XDR for configured USDC remittances', async () => { process.env.STELLAR_USDC_ISSUER = USDC_ISSUER; const remittance = await remittanceService.createRemittance({ recipientAddress: RECIPIENT, amount: 25, - fromCurrency: "USDC", - toCurrency: "USDC", - memo: "test", + fromCurrency: 'USDC', + toCurrency: 'USDC', + memo: 'test', senderAddress: SENDER, }); @@ -127,7 +122,7 @@ describe("remittanceService.createRemittance", () => { asset: { getCode: () => string; getIssuer: () => string }; }; - expect(payment.asset.getCode()).toBe("USDC"); + expect(payment.asset.getCode()).toBe('USDC'); expect(payment.asset.getIssuer()).toBe(USDC_ISSUER); }); @@ -135,9 +130,9 @@ describe("remittanceService.createRemittance", () => { const remittance = await remittanceService.createRemittance({ recipientAddress: RECIPIENT, amount: 25, - fromCurrency: "XLM", - toCurrency: "XLM", - memo: "test", + fromCurrency: 'XLM', + toCurrency: 'XLM', + memo: 'test', senderAddress: SENDER, }); @@ -145,84 +140,79 @@ describe("remittanceService.createRemittance", () => { const tx = TransactionBuilder.fromXDR(remittance.xdr!, Networks.TESTNET); expect(tx.source).toBe(SENDER); - expect(tx.sequence).toBe("12346"); + expect(tx.sequence).toBe('12346'); }); }); -describe("remittanceService.getRemittances with filters", () => { +describe('remittanceService.getRemittances with filters', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("filters remittances by status", async () => { + it('filters remittances by status', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { - id: "remit-1", + id: 'remit-1', sender_id: SENDER, recipient_address: RECIPIENT, - amount: "100", - from_currency: "USDC", - to_currency: "USDC", + amount: '100', + from_currency: 'USDC', + to_currency: 'USDC', memo: null, - status: "completed", - transaction_hash: "tx123", - xdr: "xdr123", - created_at: new Date("2024-03-01"), - updated_at: new Date("2024-03-01"), + status: 'completed', + transaction_hash: 'tx123', + xdr: 'xdr123', + created_at: new Date('2024-03-01'), + updated_at: new Date('2024-03-01'), }, ], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); mockQuery.mockResolvedValueOnce({ - rows: [{ total: "1" }], - command: "SELECT", + rows: [{ total: '1' }], + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); - const result = await remittanceService.getRemittances( - SENDER, - 20, - null, - "completed", - ); + const result = await remittanceService.getRemittances(SENDER, 20, null, 'completed'); expect(result.remittances).toHaveLength(1); - expect(result.remittances[0]!.status).toBe("completed"); + expect(result.remittances[0]!.status).toBe('completed'); expect(result.total).toBe(1); }); - it("filters remittances by date range", async () => { + it('filters remittances by date range', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { - id: "remit-2", + id: 'remit-2', sender_id: SENDER, recipient_address: RECIPIENT, - amount: "50", - from_currency: "USDC", - to_currency: "USDC", + amount: '50', + from_currency: 'USDC', + to_currency: 'USDC', memo: null, - status: "completed", - transaction_hash: "tx456", - xdr: "xdr456", - created_at: new Date("2024-03-15"), - updated_at: new Date("2024-03-15"), + status: 'completed', + transaction_hash: 'tx456', + xdr: 'xdr456', + created_at: new Date('2024-03-15'), + updated_at: new Date('2024-03-15'), }, ], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); mockQuery.mockResolvedValueOnce({ - rows: [{ total: "1" }], - command: "SELECT", + rows: [{ total: '1' }], + command: 'SELECT', rowCount: 1, oid: 0, fields: [], @@ -233,40 +223,40 @@ describe("remittanceService.getRemittances with filters", () => { 20, null, undefined, - "2024-03-01", - "2024-03-31", + '2024-03-01', + '2024-03-31', ); expect(result.remittances).toHaveLength(1); expect(result.total).toBe(1); }); - it("searches remittances by recipient address", async () => { + it('searches remittances by recipient address', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { - id: "remit-3", + id: 'remit-3', sender_id: SENDER, recipient_address: RECIPIENT, - amount: "75", - from_currency: "USDC", - to_currency: "USDC", + amount: '75', + from_currency: 'USDC', + to_currency: 'USDC', memo: null, - status: "completed", - transaction_hash: "tx789", - xdr: "xdr789", - created_at: new Date("2024-03-10"), - updated_at: new Date("2024-03-10"), + status: 'completed', + transaction_hash: 'tx789', + xdr: 'xdr789', + created_at: new Date('2024-03-10'), + updated_at: new Date('2024-03-10'), }, ], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); mockQuery.mockResolvedValueOnce({ - rows: [{ total: "1" }], - command: "SELECT", + rows: [{ total: '1' }], + command: 'SELECT', rowCount: 1, oid: 0, fields: [], @@ -286,32 +276,32 @@ describe("remittanceService.getRemittances with filters", () => { expect(result.remittances[0]!.recipientAddress).toBe(RECIPIENT); }); - it("combines multiple filters", async () => { + it('combines multiple filters', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { - id: "remit-4", + id: 'remit-4', sender_id: SENDER, recipient_address: RECIPIENT, - amount: "200", - from_currency: "USDC", - to_currency: "USDC", - memo: "payment", - status: "completed", - transaction_hash: "tx999", - xdr: "xdr999", - created_at: new Date("2024-03-20"), - updated_at: new Date("2024-03-20"), + amount: '200', + from_currency: 'USDC', + to_currency: 'USDC', + memo: 'payment', + status: 'completed', + transaction_hash: 'tx999', + xdr: 'xdr999', + created_at: new Date('2024-03-20'), + updated_at: new Date('2024-03-20'), }, ], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); mockQuery.mockResolvedValueOnce({ - rows: [{ total: "1" }], - command: "SELECT", + rows: [{ total: '1' }], + command: 'SELECT', rowCount: 1, oid: 0, fields: [], @@ -321,25 +311,19 @@ describe("remittanceService.getRemittances with filters", () => { SENDER, 20, null, - "completed", - "2024-03-01", - "2024-03-31", - "payment", + 'completed', + '2024-03-01', + '2024-03-31', + 'payment', ); expect(result.remittances).toHaveLength(1); - expect(result.remittances[0]!.status).toBe("completed"); + expect(result.remittances[0]!.status).toBe('completed'); }); - it("rejects invalid date formats", async () => { + it('rejects invalid date formats', async () => { await expect( - remittanceService.getRemittances( - SENDER, - 20, - null, - undefined, - "invalid-date", - ), + remittanceService.getRemittances(SENDER, 20, null, undefined, 'invalid-date'), ).rejects.toThrow("Invalid 'from' date format"); }); }); diff --git a/backend/src/__tests__/requestId.test.ts b/backend/src/__tests__/requestId.test.ts index 6e268740..ea2cebe5 100644 --- a/backend/src/__tests__/requestId.test.ts +++ b/backend/src/__tests__/requestId.test.ts @@ -1,49 +1,47 @@ -import request from "supertest"; -import app from "../app.js"; -import logger from "../utils/logger.js"; -import { jest } from "@jest/globals"; +import request from 'supertest'; +import app from '../app.js'; +import logger from '../utils/logger.js'; +import { jest } from '@jest/globals'; -import express from "express"; -import { requestIdMiddleware } from "../middleware/requestId.js"; +import express from 'express'; +import { requestIdMiddleware } from '../middleware/requestId.js'; -describe("Request ID middleware", () => { - it("adds x-request-id when missing", async () => { - const response = await request(app).get("/"); - const requestId = response.headers["x-request-id"] as string | undefined; +describe('Request ID middleware', () => { + it('adds x-request-id when missing', async () => { + const response = await request(app).get('/'); + const requestId = response.headers['x-request-id'] as string | undefined; expect(response.status).toBe(200); expect(requestId).toBeDefined(); - expect(typeof requestId).toBe("string"); - expect((requestId ?? "").length).toBeGreaterThan(0); + expect(typeof requestId).toBe('string'); + expect((requestId ?? '').length).toBeGreaterThan(0); }); - it("preserves client x-request-id", async () => { - const requestId = "test-request-id-123"; + it('preserves client x-request-id', async () => { + const requestId = 'test-request-id-123'; - const response = await request(app).get("/").set("x-request-id", requestId); + const response = await request(app).get('/').set('x-request-id', requestId); expect(response.status).toBe(200); - expect(response.headers["x-request-id"]).toBe(requestId); + expect(response.headers['x-request-id']).toBe(requestId); }); - it("correlates logger requestId with x-request-id via withContext", async () => { + it('correlates logger requestId with x-request-id via withContext', async () => { const tempApp = express(); tempApp.use(requestIdMiddleware); - tempApp.get("/test", (req, res) => { - logger.withContext().info("Testing withContext correlation"); + tempApp.get('/test', (req, res) => { + logger.withContext().info('Testing withContext correlation'); res.sendStatus(200); }); - const infoSpy = jest - .spyOn(logger, "info") - .mockImplementation(() => logger as any); + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => logger as any); - const response = await request(tempApp).get("/test"); - const requestId = response.headers["x-request-id"]; + const response = await request(tempApp).get('/test'); + const requestId = response.headers['x-request-id']; expect(response.status).toBe(200); expect(infoSpy).toHaveBeenCalledWith( - "Testing withContext correlation", + 'Testing withContext correlation', expect.objectContaining({ requestId }), ); diff --git a/backend/src/__tests__/score.test.ts b/backend/src/__tests__/score.test.ts index a9fadd51..21f253b0 100644 --- a/backend/src/__tests__/score.test.ts +++ b/backend/src/__tests__/score.test.ts @@ -1,8 +1,8 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import request from 'supertest'; // Mock the database connection module before any other imports -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), getClient: jest.fn(), withTransaction: jest.fn(), @@ -12,24 +12,24 @@ jest.unstable_mockModule("../db/connection.js", () => ({ })); // Mock CacheService to prevent Redis connections -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); // Dynamic imports to ensure mocks are applied -const { query } = await import("../db/connection.js"); -const { generateJwtToken } = await import("../services/authService.js"); +const { query } = await import('../db/connection.js'); +const { generateJwtToken } = await import('../services/authService.js'); // Set env vars -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; -process.env.INTERNAL_API_KEY = "test-internal-key"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; +process.env.INTERNAL_API_KEY = 'test-internal-key'; -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); const mockedQuery = query as jest.MockedFunction; @@ -37,107 +37,101 @@ const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, }); -describe("GET /api/score/:userId", () => { +describe('GET /api/score/:userId', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("should reject unauthenticated requests", async () => { - const response = await request(app).get("/api/score/user123"); + it('should reject unauthenticated requests', async () => { + const response = await request(app).get('/api/score/user123'); expect(response.status).toBe(401); }); - it("should reject when path userId does not match JWT wallet", async () => { - const response = await request(app) - .get("/api/score/user123") - .set(bearer("other-wallet")); + it('should reject when path userId does not match JWT wallet', async () => { + const response = await request(app).get('/api/score/user123').set(bearer('other-wallet')); expect(response.status).toBe(403); }); - it("should return a score for a valid userId", async () => { + it('should return a score for a valid userId', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ current_score: 750 }], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); - const response = await request(app) - .get("/api/score/user123") - .set(bearer("user123")); + const response = await request(app).get('/api/score/user123').set(bearer('user123')); expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.userId).toBe("user123"); + expect(response.body.userId).toBe('user123'); expect(response.body.score).toBe(750); }); - it("should return the same score for the same userId", async () => { + it('should return the same score for the same userId', async () => { mockedQuery.mockResolvedValue({ rows: [{ current_score: 600 }], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); - const r1 = await request(app).get("/api/score/alice").set(bearer("alice")); - const r2 = await request(app).get("/api/score/alice").set(bearer("alice")); + const r1 = await request(app).get('/api/score/alice').set(bearer('alice')); + const r2 = await request(app).get('/api/score/alice').set(bearer('alice')); expect(r1.body.score).toBe(r2.body.score); }); - it("should return 500 if user not found", async () => { + it('should return 500 if user not found', async () => { mockedQuery.mockResolvedValueOnce({ rows: [], - command: "SELECT", + command: 'SELECT', rowCount: 0, oid: 0, fields: [], }); - const response = await request(app) - .get("/api/score/newuser") - .set(bearer("newuser")); + const response = await request(app).get('/api/score/newuser').set(bearer('newuser')); expect(response.status).toBe(200); expect(response.body.score).toBe(500); }); }); -describe("POST /api/score/update", () => { - it("should increase score by 15 for on-time repayment", async () => { +describe('POST /api/score/update', () => { + it('should increase score by 15 for on-time repayment', async () => { mockedQuery.mockResolvedValueOnce({ rows: [{ current_score: 500 }], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); mockedQuery.mockResolvedValueOnce({ rows: [{ current_score: 515 }], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], }); const response = await request(app) - .post("/api/score/update") - .set("x-api-key", "test-internal-key") - .send({ userId: "user123", repaymentAmount: 500, onTime: true }); + .post('/api/score/update') + .set('x-api-key', 'test-internal-key') + .send({ userId: 'user123', repaymentAmount: 500, onTime: true }); expect(response.status).toBe(200); expect(response.body.newScore).toBe(515); }); - it("should reject negative repaymentAmount", async () => { + it('should reject negative repaymentAmount', async () => { const response = await request(app) - .post("/api/score/update") - .set("x-api-key", "test-internal-key") - .send({ userId: "user123", repaymentAmount: -100, onTime: true }); + .post('/api/score/update') + .set('x-api-key', 'test-internal-key') + .send({ userId: 'user123', repaymentAmount: -100, onTime: true }); expect(response.status).toBe(400); }); diff --git a/backend/src/__tests__/scoreBreakdown.test.ts b/backend/src/__tests__/scoreBreakdown.test.ts index c2ecbd73..e19285f0 100644 --- a/backend/src/__tests__/scoreBreakdown.test.ts +++ b/backend/src/__tests__/scoreBreakdown.test.ts @@ -1,9 +1,9 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; -import jwt from "jsonwebtoken"; +import { jest } from '@jest/globals'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; // Mock the database connection module before any other imports -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), getClient: jest.fn(), withTransaction: jest.fn(), @@ -13,24 +13,24 @@ jest.unstable_mockModule("../db/connection.js", () => ({ })); // Mock CacheService to prevent Redis connections -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); // Dynamic imports to ensure mocks are applied -const { query } = await import("../db/connection.js"); -const { generateJwtToken } = await import("../services/authService.js"); +const { query } = await import('../db/connection.js'); +const { generateJwtToken } = await import('../services/authService.js'); // Set env vars -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; -process.env.INTERNAL_API_KEY = "test-internal-key"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; +process.env.INTERNAL_API_KEY = 'test-internal-key'; -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); const mockedQuery = query as jest.MockedFunction; @@ -38,35 +38,35 @@ const bearer = (publicKey: string) => ({ Authorization: `Bearer ${generateJwtToken(publicKey)}`, }); -describe("GET /api/score/:userId/breakdown", () => { +describe('GET /api/score/:userId/breakdown', () => { beforeEach(() => { jest.clearAllMocks(); }); - it("should reject unauthenticated requests", async () => { - const response = await request(app).get("/api/score/user123/breakdown"); + it('should reject unauthenticated requests', async () => { + const response = await request(app).get('/api/score/user123/breakdown'); expect(response.status).toBe(401); }); - it("should return 403 for a token lacking read:score scope", async () => { + it('should return 403 for a token lacking read:score scope', async () => { const tokenWithoutReadScore = jwt.sign( { - publicKey: "user123", - role: "lender", - scopes: ["read:loans", "read:pool"], + publicKey: 'user123', + role: 'lender', + scopes: ['read:loans', 'read:pool'], }, process.env.JWT_SECRET!, - { algorithm: "HS256", expiresIn: "1h" }, + { algorithm: 'HS256', expiresIn: '1h' }, ); const response = await request(app) - .get("/api/score/user123/breakdown") - .set("Authorization", `Bearer ${tokenWithoutReadScore}`); + .get('/api/score/user123/breakdown') + .set('Authorization', `Bearer ${tokenWithoutReadScore}`); expect(response.status).toBe(403); }); - it("should return a breakdown for a valid userId", async () => { + it('should return a breakdown for a valid userId', async () => { // Mock the optimized single CTE query (returns all breakdown metrics) mockedQuery.mockResolvedValueOnce({ rows: [ @@ -81,7 +81,7 @@ describe("GET /api/score/:userId/breakdown", () => { avg_repayment_ledgers: 17280, }, ], - command: "SELECT", + command: 'SELECT', rowCount: 1, oid: 0, fields: [], @@ -89,27 +89,25 @@ describe("GET /api/score/:userId/breakdown", () => { mockedQuery.mockResolvedValueOnce({ rows: [ { - event_type: "LoanRepaid", - ledger_closed_at: "2026-03-01T10:00:00Z", + event_type: 'LoanRepaid', + ledger_closed_at: '2026-03-01T10:00:00Z', }, { - event_type: "LoanRepaid", - ledger_closed_at: "2026-03-05T10:00:00Z", + event_type: 'LoanRepaid', + ledger_closed_at: '2026-03-05T10:00:00Z', }, { - event_type: "LoanRepaid", - ledger_closed_at: "2026-03-10T10:00:00Z", + event_type: 'LoanRepaid', + ledger_closed_at: '2026-03-10T10:00:00Z', }, ], - command: "SELECT", + command: 'SELECT', rowCount: 3, oid: 0, fields: [], }); // History query - const response = await request(app) - .get("/api/score/user123/breakdown") - .set(bearer("user123")); + const response = await request(app).get('/api/score/user123/breakdown').set(bearer('user123')); expect(response.status).toBe(200); expect(response.body.score).toBe(720); @@ -120,27 +118,25 @@ describe("GET /api/score/:userId/breakdown", () => { expect(response.body.history).toHaveLength(3); }); - it("should return default values for a user with no history", async () => { + it('should return default values for a user with no history', async () => { // Mock empty breakdown and history queries mockedQuery .mockResolvedValueOnce({ rows: [], - command: "SELECT", + command: 'SELECT', rowCount: 0, oid: 0, fields: [], }) // Empty breakdown .mockResolvedValueOnce({ rows: [], - command: "SELECT", + command: 'SELECT', rowCount: 0, oid: 0, fields: [], }); // Empty history - const response = await request(app) - .get("/api/score/newuser/breakdown") - .set(bearer("newuser")); + const response = await request(app).get('/api/score/newuser/breakdown').set(bearer('newuser')); expect(response.status).toBe(200); expect(response.body.score).toBe(500); diff --git a/backend/src/__tests__/scoreConfig.test.ts b/backend/src/__tests__/scoreConfig.test.ts index fd0b1a0d..84d71fd0 100644 --- a/backend/src/__tests__/scoreConfig.test.ts +++ b/backend/src/__tests__/scoreConfig.test.ts @@ -1,7 +1,7 @@ /** * Tests for issue #469 — score deltas sourced from config, not hardcoded. */ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; // ── mockGetScoreConfig reads env vars just like the real implementation ─── interface ScoreConfig { @@ -9,24 +9,17 @@ interface ScoreConfig { defaultPenalty: number; } -const mockGetScoreConfig = jest - .fn<() => ScoreConfig>() - .mockImplementation(() => ({ - repaymentDelta: parseInt(process.env.SCORE_REPAYMENT_DELTA ?? "15", 10), - defaultPenalty: parseInt(process.env.SCORE_DEFAULT_PENALTY ?? "50", 10), - })); +const mockGetScoreConfig = jest.fn<() => ScoreConfig>().mockImplementation(() => ({ + repaymentDelta: parseInt(process.env.SCORE_REPAYMENT_DELTA ?? '15', 10), + defaultPenalty: parseInt(process.env.SCORE_DEFAULT_PENALTY ?? '50', 10), +})); const mockQuery = jest - .fn< - ( - sql?: string, - params?: unknown[], - ) => Promise<{ rows: unknown[]; rowCount: number }> - >() + .fn<(sql?: string, params?: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>() .mockResolvedValue({ rows: [], rowCount: 0 }); // All ESM mocks must be declared before any dynamic import -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -46,18 +39,16 @@ jest.unstable_mockModule("../db/connection.js", () => ({ }) => Promise, ) => fn({ - query: jest.fn((sql: string, params?: unknown[]) => - mockQuery(sql, params ?? []), - ), + query: jest.fn((sql: string, params?: unknown[]) => mockQuery(sql, params ?? [])), }), ), })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { getScoreConfig: mockGetScoreConfig }, })); -jest.unstable_mockModule("../services/webhookService.js", () => ({ +jest.unstable_mockModule('../services/webhookService.js', () => ({ SUPPORTED_WEBHOOK_EVENT_TYPES: [], webhookService: { dispatch: jest.fn<() => Promise>().mockResolvedValue(undefined), @@ -65,11 +56,11 @@ jest.unstable_mockModule("../services/webhookService.js", () => ({ WebhookEventType: {}, })); -jest.unstable_mockModule("../services/eventStreamService.js", () => ({ +jest.unstable_mockModule('../services/eventStreamService.js', () => ({ eventStreamService: { broadcast: jest.fn() }, })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), @@ -77,49 +68,47 @@ jest.unstable_mockModule("../services/cacheService.js", () => ({ }, })); -jest.unstable_mockModule("../services/notificationService.js", () => ({ +jest.unstable_mockModule('../services/notificationService.js', () => ({ notificationService: { - createNotification: jest - .fn<() => Promise>() - .mockResolvedValue(undefined), + createNotification: jest.fn<() => Promise>().mockResolvedValue(undefined), }, })); // ── SorobanService.getScoreConfig — tests the env-var reading logic ─────── -describe("SorobanService.getScoreConfig()", () => { +describe('SorobanService.getScoreConfig()', () => { afterEach(() => { delete process.env.SCORE_REPAYMENT_DELTA; delete process.env.SCORE_DEFAULT_PENALTY; mockGetScoreConfig.mockClear(); }); - it("returns default repaymentDelta of 15 when env var is not set", () => { + it('returns default repaymentDelta of 15 when env var is not set', () => { delete process.env.SCORE_REPAYMENT_DELTA; const cfg = mockGetScoreConfig(); expect(cfg.repaymentDelta).toBe(15); }); - it("returns default defaultPenalty of 50 when env var is not set", () => { + it('returns default defaultPenalty of 50 when env var is not set', () => { delete process.env.SCORE_DEFAULT_PENALTY; const cfg = mockGetScoreConfig(); expect(cfg.defaultPenalty).toBe(50); }); - it("returns repaymentDelta from SCORE_REPAYMENT_DELTA env var", () => { - process.env.SCORE_REPAYMENT_DELTA = "20"; + it('returns repaymentDelta from SCORE_REPAYMENT_DELTA env var', () => { + process.env.SCORE_REPAYMENT_DELTA = '20'; const cfg = mockGetScoreConfig(); expect(cfg.repaymentDelta).toBe(20); }); - it("returns defaultPenalty from SCORE_DEFAULT_PENALTY env var", () => { - process.env.SCORE_DEFAULT_PENALTY = "75"; + it('returns defaultPenalty from SCORE_DEFAULT_PENALTY env var', () => { + process.env.SCORE_DEFAULT_PENALTY = '75'; const cfg = mockGetScoreConfig(); expect(cfg.defaultPenalty).toBe(75); }); }); // ── EventIndexer uses getScoreConfig, not hardcoded values ─────────────── -describe("EventIndexer score delta wiring", () => { +describe('EventIndexer score delta wiring', () => { // Parsed event shape that storeEvents expects (post-parseEvent) const makeEvent = (eventId: string, eventType: string, borrower: string) => ({ eventId, @@ -127,24 +116,25 @@ describe("EventIndexer score delta wiring", () => { borrower, ledger: 100, ledgerClosedAt: new Date(), - txHash: "abc", - contractId: "CTEST", + txHash: 'abc', + contractId: 'CTEST', topics: [], - value: "", - amount: "500", + value: '', + amount: '500', loanId: 1, }); async function buildIndexer() { - const { EventIndexer } = await import("../services/eventIndexer.js"); + const { EventIndexer } = await import('../services/eventIndexer.js'); const indexer = new EventIndexer({ - rpcUrl: "https://soroban-testnet.stellar.org", - contractId: "CTEST", + rpcUrl: 'https://soroban-testnet.stellar.org', + contractId: 'CTEST', }); // Bypass XDR parsing — return the event as-is so storeEvents can process it - (indexer as unknown as { parseEvent: (e: unknown) => unknown }).parseEvent = - jest.fn().mockImplementation((e: unknown) => e); + (indexer as unknown as { parseEvent: (e: unknown) => unknown }).parseEvent = jest + .fn() + .mockImplementation((e: unknown) => e); const storeEvents = ( indexer as unknown as { @@ -160,24 +150,24 @@ describe("EventIndexer score delta wiring", () => { mockQuery.mockReset().mockResolvedValue({ rows: [], rowCount: 0 }); }); - it.skip("calls sorobanService.getScoreConfig for LoanRepaid events", async () => { + it.skip('calls sorobanService.getScoreConfig for LoanRepaid events', async () => { mockQuery - .mockResolvedValueOnce({ rows: [{ event_id: "evt-1" }], rowCount: 1 }) // INSERT + .mockResolvedValueOnce({ rows: [{ event_id: 'evt-1' }], rowCount: 1 }) // INSERT .mockResolvedValueOnce({ rows: [], rowCount: 1 }); // score upsert const { storeEvents } = await buildIndexer(); - await storeEvents([makeEvent("evt-1", "LoanRepaid", "GABC")]); + await storeEvents([makeEvent('evt-1', 'LoanRepaid', 'GABC')]); expect(mockGetScoreConfig).toHaveBeenCalled(); }, 20000); - it.skip("calls sorobanService.getScoreConfig for LoanDefaulted events", async () => { + it.skip('calls sorobanService.getScoreConfig for LoanDefaulted events', async () => { mockQuery - .mockResolvedValueOnce({ rows: [{ event_id: "evt-2" }], rowCount: 1 }) // INSERT + .mockResolvedValueOnce({ rows: [{ event_id: 'evt-2' }], rowCount: 1 }) // INSERT .mockResolvedValueOnce({ rows: [], rowCount: 1 }); // score upsert const { storeEvents } = await buildIndexer(); - await storeEvents([makeEvent("evt-2", "LoanDefaulted", "GDEF")]); + await storeEvents([makeEvent('evt-2', 'LoanDefaulted', 'GDEF')]); expect(mockGetScoreConfig).toHaveBeenCalled(); }); diff --git a/backend/src/__tests__/scoreReconciliationService.test.ts b/backend/src/__tests__/scoreReconciliationService.test.ts index 025acd84..2cd33cf0 100644 --- a/backend/src/__tests__/scoreReconciliationService.test.ts +++ b/backend/src/__tests__/scoreReconciliationService.test.ts @@ -1,41 +1,36 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -const mockGetOnChainCreditScore = - jest.fn<(userPublicKey: string) => Promise>(); -const mockSetAbsoluteUserScoresBulk = - jest.fn<(scores: Map) => Promise>(); +const mockGetOnChainCreditScore = jest.fn<(userPublicKey: string) => Promise>(); +const mockSetAbsoluteUserScoresBulk = jest.fn<(scores: Map) => Promise>(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), closePool: jest.fn(), })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { getOnChainCreditScore: mockGetOnChainCreditScore, }, })); -jest.unstable_mockModule("../services/scoresService.js", () => ({ +jest.unstable_mockModule('../services/scoresService.js', () => ({ setAbsoluteUserScoresBulk: mockSetAbsoluteUserScoresBulk, })); -const logger = (await import("../utils/logger.js")).default; -const { scoreReconciliationService } = - await import("../services/scoreReconciliationService.js"); +const logger = (await import('../utils/logger.js')).default; +const { scoreReconciliationService } = await import('../services/scoreReconciliationService.js'); -describe("scoreReconciliationService", () => { - const originalAutoCorrectEnabled = - process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED; - const originalAutoCorrectThreshold = - process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD; +describe('scoreReconciliationService', () => { + const originalAutoCorrectEnabled = process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED; + const originalAutoCorrectThreshold = process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD; const originalBatchSize = process.env.SCORE_RECONCILIATION_BATCH_SIZE; afterEach(() => { @@ -45,15 +40,13 @@ describe("scoreReconciliationService", () => { if (originalAutoCorrectEnabled === undefined) { delete process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED; } else { - process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED = - originalAutoCorrectEnabled; + process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED = originalAutoCorrectEnabled; } if (originalAutoCorrectThreshold === undefined) { delete process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD; } else { - process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD = - originalAutoCorrectThreshold; + process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD = originalAutoCorrectThreshold; } if (originalBatchSize === undefined) { @@ -63,23 +56,19 @@ describe("scoreReconciliationService", () => { } }); - it("logs divergences and auto-corrects borrowers above the threshold", async () => { - process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED = "true"; - process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD = "40"; - process.env.SCORE_RECONCILIATION_BATCH_SIZE = "2"; + it('logs divergences and auto-corrects borrowers above the threshold', async () => { + process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED = 'true'; + process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD = '40'; + process.env.SCORE_RECONCILIATION_BATCH_SIZE = '2'; - const infoSpy = jest - .spyOn(logger, "info") - .mockImplementation(() => logger as typeof logger); - const warnSpy = jest - .spyOn(logger, "warn") - .mockImplementation(() => logger as typeof logger); + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => logger as typeof logger); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger as typeof logger); mockQuery.mockResolvedValueOnce({ rows: [ - { address: "GBORROWER1", current_score: 700 }, - { address: "GBORROWER2", current_score: 600 }, - { address: "GBORROWER3", current_score: null }, + { address: 'GBORROWER1', current_score: 700 }, + { address: 'GBORROWER2', current_score: 600 }, + { address: 'GBORROWER3', current_score: null }, ], }); @@ -89,8 +78,7 @@ describe("scoreReconciliationService", () => { .mockResolvedValueOnce(620); mockSetAbsoluteUserScoresBulk.mockResolvedValueOnce(); - const result = - await scoreReconciliationService.reconcileActiveBorrowerScores(); + const result = await scoreReconciliationService.reconcileActiveBorrowerScores(); expect(result).toMatchObject({ activeBorrowerCount: 3, @@ -103,13 +91,13 @@ describe("scoreReconciliationService", () => { }); expect(result.divergences).toEqual([ { - address: "GBORROWER2", + address: 'GBORROWER2', dbScore: 600, contractScore: 660, absoluteDifference: 60, }, { - address: "GBORROWER3", + address: 'GBORROWER3', dbScore: null, contractScore: 620, absoluteDifference: null, @@ -117,19 +105,19 @@ describe("scoreReconciliationService", () => { ]); expect(mockSetAbsoluteUserScoresBulk).toHaveBeenCalledWith( new Map([ - ["GBORROWER2", 660], - ["GBORROWER3", 620], + ['GBORROWER2', 660], + ['GBORROWER3', 620], ]), ); expect(infoSpy).toHaveBeenCalledWith( - "score_divergence_count", + 'score_divergence_count', expect.objectContaining({ - metric: "score_divergence_count", + metric: 'score_divergence_count', value: 2, }), ); expect(warnSpy).toHaveBeenCalledWith( - "score_reconciliation.autocorrect.applied", + 'score_reconciliation.autocorrect.applied', expect.objectContaining({ correctedCount: 2, threshold: 40, diff --git a/backend/src/__tests__/scoresService.test.ts b/backend/src/__tests__/scoresService.test.ts index 16a11699..fc213d79 100644 --- a/backend/src/__tests__/scoresService.test.ts +++ b/backend/src/__tests__/scoresService.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll } from "@jest/globals"; -import { query } from "../db/connection.js"; -import { updateUserScoresBulk } from "../services/scoresService.js"; +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { query } from '../db/connection.js'; +import { updateUserScoresBulk } from '../services/scoresService.js'; let __scoresService_dbAvailable = false; beforeAll(async () => { try { - await query("SELECT 1"); + await query('SELECT 1'); __scoresService_dbAvailable = true; } catch { __scoresService_dbAvailable = false; @@ -19,14 +19,14 @@ const describeIf_scoresService = (name: string, fn: () => void) => { } else { // Ensure at least one skipped test exists so Jest considers the suite valid describe.skip(name, () => { - it.skip("skipped: no database", () => {}); + it.skip('skipped: no database', () => {}); }); } }; -describeIf_scoresService("Scores Service - bulk updates", () => { - const userA = "G_TEST_USER_A"; - const userB = "G_TEST_USER_B"; +describeIf_scoresService('Scores Service - bulk updates', () => { + const userA = 'G_TEST_USER_A'; + const userB = 'G_TEST_USER_B'; beforeAll(async () => { await query(` @@ -40,12 +40,12 @@ describeIf_scoresService("Scores Service - bulk updates", () => { }); afterAll(async () => { - await query("DELETE FROM scores WHERE user_id LIKE $1", ["G_TEST_%"]); + await query('DELETE FROM scores WHERE user_id LIKE $1', ['G_TEST_%']); }); - it("applies multiple deltas in a single operation and initializes new rows", async () => { + it('applies multiple deltas in a single operation and initializes new rows', async () => { // ensure clean - await query("DELETE FROM scores WHERE user_id IN ($1, $2)", [userA, userB]); + await query('DELETE FROM scores WHERE user_id IN ($1, $2)', [userA, userB]); const updates = new Map(); updates.set(userA, 10); @@ -54,7 +54,7 @@ describeIf_scoresService("Scores Service - bulk updates", () => { await updateUserScoresBulk(updates); const res = await query( - "SELECT user_id, current_score FROM scores WHERE user_id IN ($1, $2) ORDER BY user_id", + 'SELECT user_id, current_score FROM scores WHERE user_id IN ($1, $2) ORDER BY user_id', [userA, userB], ); @@ -76,7 +76,7 @@ describeIf_scoresService("Scores Service - bulk updates", () => { await updateUserScoresBulk(more); const res2 = await query( - "SELECT user_id, current_score FROM scores WHERE user_id IN ($1, $2) ORDER BY user_id", + 'SELECT user_id, current_score FROM scores WHERE user_id IN ($1, $2) ORDER BY user_id', [userA, userB], ); diff --git a/backend/src/__tests__/stellarConfig.test.ts b/backend/src/__tests__/stellarConfig.test.ts index 63ba7e90..63ff2283 100644 --- a/backend/src/__tests__/stellarConfig.test.ts +++ b/backend/src/__tests__/stellarConfig.test.ts @@ -1,6 +1,6 @@ -import { afterEach, describe, expect, it } from "@jest/globals"; -import { Networks } from "@stellar/stellar-sdk"; -import { getStellarConfig } from "../config/stellar.js"; +import { afterEach, describe, expect, it } from '@jest/globals'; +import { Networks } from '@stellar/stellar-sdk'; +import { getStellarConfig } from '../config/stellar.js'; const originalStellarEnv = { STELLAR_NETWORK: process.env.STELLAR_NETWORK, @@ -24,8 +24,7 @@ const restoreEnv = () => { if (originalStellarEnv.STELLAR_NETWORK_PASSPHRASE === undefined) { delete process.env.STELLAR_NETWORK_PASSPHRASE; } else { - process.env.STELLAR_NETWORK_PASSPHRASE = - originalStellarEnv.STELLAR_NETWORK_PASSPHRASE; + process.env.STELLAR_NETWORK_PASSPHRASE = originalStellarEnv.STELLAR_NETWORK_PASSPHRASE; } }; @@ -33,33 +32,33 @@ afterEach(() => { restoreEnv(); }); -describe("stellar config", () => { - it("defaults to testnet settings when env vars are absent", () => { +describe('stellar config', () => { + it('defaults to testnet settings when env vars are absent', () => { delete process.env.STELLAR_NETWORK; delete process.env.STELLAR_RPC_URL; delete process.env.STELLAR_NETWORK_PASSPHRASE; const config = getStellarConfig(); - expect(config.network).toBe("testnet"); - expect(config.rpcUrl).toBe("https://soroban-testnet.stellar.org"); + expect(config.network).toBe('testnet'); + expect(config.rpcUrl).toBe('https://soroban-testnet.stellar.org'); expect(config.networkPassphrase).toBe(Networks.TESTNET); }); - it("uses mainnet defaults when STELLAR_NETWORK=mainnet", () => { - process.env.STELLAR_NETWORK = "mainnet"; + it('uses mainnet defaults when STELLAR_NETWORK=mainnet', () => { + process.env.STELLAR_NETWORK = 'mainnet'; delete process.env.STELLAR_RPC_URL; delete process.env.STELLAR_NETWORK_PASSPHRASE; const config = getStellarConfig(); - expect(config.network).toBe("mainnet"); - expect(config.rpcUrl).toBe("https://soroban-mainnet.stellar.org"); + expect(config.network).toBe('mainnet'); + expect(config.rpcUrl).toBe('https://soroban-mainnet.stellar.org'); expect(config.networkPassphrase).toBe(Networks.PUBLIC); }); - it("rejects passphrase/network mismatches", () => { - process.env.STELLAR_NETWORK = "mainnet"; + it('rejects passphrase/network mismatches', () => { + process.env.STELLAR_NETWORK = 'mainnet'; process.env.STELLAR_NETWORK_PASSPHRASE = Networks.TESTNET; expect(() => getStellarConfig()).toThrow( @@ -67,9 +66,9 @@ describe("stellar config", () => { ); }); - it("rejects rpc url/network mismatches", () => { - process.env.STELLAR_NETWORK = "mainnet"; - process.env.STELLAR_RPC_URL = "https://soroban-testnet.stellar.org"; + it('rejects rpc url/network mismatches', () => { + process.env.STELLAR_NETWORK = 'mainnet'; + process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org'; expect(() => getStellarConfig()).toThrow( 'STELLAR_RPC_URL appears to target testnet while STELLAR_NETWORK is "mainnet".', diff --git a/backend/src/__tests__/swaggerDocs.test.ts b/backend/src/__tests__/swaggerDocs.test.ts index 5af2b7b2..a42db5a6 100644 --- a/backend/src/__tests__/swaggerDocs.test.ts +++ b/backend/src/__tests__/swaggerDocs.test.ts @@ -1,7 +1,7 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import request from 'supertest'; -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: jest .fn<() => Promise<{ rows: unknown[]; rowCount: number }>>() @@ -14,15 +14,15 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), getScoreConfig: jest.fn(() => ({ repaymentDelta: 20, defaultPenalty: 50, @@ -30,9 +30,9 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ }, })); -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); -describe("Swagger docs", () => { +describe('Swagger docs', () => { const originalNodeEnv = process.env.NODE_ENV; const originalEnableSwagger = process.env.ENABLE_SWAGGER; @@ -45,34 +45,34 @@ describe("Swagger docs", () => { } }); - it("serves Swagger UI and raw OpenAPI JSON when enabled", async () => { - process.env.NODE_ENV = "test"; + it('serves Swagger UI and raw OpenAPI JSON when enabled', async () => { + process.env.NODE_ENV = 'test'; delete process.env.ENABLE_SWAGGER; - const docsResponse = await request(app).get("/docs/"); + const docsResponse = await request(app).get('/docs/'); expect(docsResponse.status).toBe(200); - expect(docsResponse.text).toContain("Swagger UI"); + expect(docsResponse.text).toContain('Swagger UI'); - const jsonResponse = await request(app).get("/docs.json"); + const jsonResponse = await request(app).get('/docs.json'); expect(jsonResponse.status).toBe(200); - expect(jsonResponse.body.openapi).toBe("3.0.0"); + expect(jsonResponse.body.openapi).toBe('3.0.0'); expect(jsonResponse.body.components.schemas.ErrorResponse).toBeDefined(); }); - it("returns 404 for docs endpoints in production unless explicitly enabled", async () => { - process.env.NODE_ENV = "production"; + it('returns 404 for docs endpoints in production unless explicitly enabled', async () => { + process.env.NODE_ENV = 'production'; delete process.env.ENABLE_SWAGGER; - await request(app).get("/docs/").expect(404); - await request(app).get("/docs.json").expect(404); + await request(app).get('/docs/').expect(404); + await request(app).get('/docs.json').expect(404); }); - it("allows docs in production when ENABLE_SWAGGER=true", async () => { - process.env.NODE_ENV = "production"; - process.env.ENABLE_SWAGGER = "true"; + it('allows docs in production when ENABLE_SWAGGER=true', async () => { + process.env.NODE_ENV = 'production'; + process.env.ENABLE_SWAGGER = 'true'; - await request(app).get("/docs/").expect(200); - await request(app).get("/docs.json").expect(200); + await request(app).get('/docs/').expect(200); + await request(app).get('/docs.json').expect(200); }); it("API routes do not have unsafe-inline in script-src CSP", async () => { diff --git a/backend/src/__tests__/userProfile.test.ts b/backend/src/__tests__/userProfile.test.ts index 9098cfc0..d3cc1b83 100644 --- a/backend/src/__tests__/userProfile.test.ts +++ b/backend/src/__tests__/userProfile.test.ts @@ -1,8 +1,8 @@ -import { jest } from "@jest/globals"; -import jwt from "jsonwebtoken"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; -process.env.JWT_SECRET = "user-profile-test-secret"; +process.env.JWT_SECRET = 'user-profile-test-secret'; const queryMock = jest.fn< () => Promise<{ @@ -14,7 +14,7 @@ const queryMock = jest.fn< }> >(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: queryMock, }, @@ -23,15 +23,15 @@ jest.unstable_mockModule("../db/connection.js", () => ({ withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), getScoreConfig: jest.fn(() => ({ repaymentDelta: 20, defaultPenalty: 50, @@ -39,19 +39,19 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ }, })); -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); -const publicKey = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const publicKey = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; function bearerToken() { return jwt.sign( { publicKey, - role: "borrower", - scopes: ["read:profile", "write:profile"], + role: 'borrower', + scopes: ['read:profile', 'write:profile'], }, process.env.JWT_SECRET!, - { expiresIn: "1h", algorithm: "HS256" }, + { expiresIn: '1h', algorithm: 'HS256' }, ); } @@ -65,13 +65,13 @@ function profileRow(overrides: Record = {}) { email_enabled: false, sms_enabled: false, metadata: {}, - created_at: "2026-05-27T00:00:00.000Z", - updated_at: "2026-05-27T00:00:00.000Z", + created_at: '2026-05-27T00:00:00.000Z', + updated_at: '2026-05-27T00:00:00.000Z', ...overrides, }; } -describe("/user/profile", () => { +describe('/user/profile', () => { beforeEach(() => { queryMock.mockReset(); }); @@ -80,103 +80,97 @@ describe("/user/profile", () => { queryMock.mockResolvedValueOnce({ rows: [profileRow()], rowCount: 1, - command: "SELECT", + command: 'SELECT', oid: 0, fields: [], }); const response = await request(app) - .get("/user/profile") - .set("Authorization", `Bearer ${bearerToken()}`); + .get('/user/profile') + .set('Authorization', `Bearer ${bearerToken()}`); expect(response.status).toBe(200); expect(response.body).toMatchObject({ - id: "12", - email: "", + id: '12', + email: '', walletAddress: publicKey, kycVerified: false, - displayName: "", - phone: "", + displayName: '', + phone: '', }); - expect(queryMock).toHaveBeenCalledWith( - expect.stringContaining("INSERT INTO user_profiles"), - [publicKey], - ); + expect(queryMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO user_profiles'), [ + publicKey, + ]); }); - it("updates allowed profile fields after validation", async () => { + it('updates allowed profile fields after validation', async () => { queryMock .mockResolvedValueOnce({ rows: [profileRow({ metadata: { kycVerified: true } })], rowCount: 1, - command: "SELECT", + command: 'SELECT', oid: 0, fields: [], }) .mockResolvedValueOnce({ rows: [ profileRow({ - display_name: "Ada Lovelace", - email: "ada@example.com", - phone: "+15551234567", + display_name: 'Ada Lovelace', + email: 'ada@example.com', + phone: '+15551234567', metadata: { kycVerified: true, - locale: "en-US", - avatarUrl: "https://example.com/avatar.png", + locale: 'en-US', + avatarUrl: 'https://example.com/avatar.png', }, }), ], rowCount: 1, - command: "SELECT", + command: 'SELECT', oid: 0, fields: [], }); const response = await request(app) - .patch("/user/profile") - .set("Authorization", `Bearer ${bearerToken()}`) + .patch('/user/profile') + .set('Authorization', `Bearer ${bearerToken()}`) .send({ - displayName: "Ada Lovelace", - email: "ada@example.com", - phone: "+15551234567", - locale: "en-US", - avatarUrl: "https://example.com/avatar.png", + displayName: 'Ada Lovelace', + email: 'ada@example.com', + phone: '+15551234567', + locale: 'en-US', + avatarUrl: 'https://example.com/avatar.png', }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ - email: "ada@example.com", + email: 'ada@example.com', walletAddress: publicKey, kycVerified: true, - displayName: "Ada Lovelace", - phone: "+15551234567", - locale: "en-US", - avatarUrl: "https://example.com/avatar.png", + displayName: 'Ada Lovelace', + phone: '+15551234567', + locale: 'en-US', + avatarUrl: 'https://example.com/avatar.png', }); expect(queryMock).toHaveBeenLastCalledWith( - expect.stringContaining("UPDATE user_profiles"), - expect.arrayContaining([ - "Ada Lovelace", - "ada@example.com", - "+15551234567", - publicKey, - ]), + expect.stringContaining('UPDATE user_profiles'), + expect.arrayContaining(['Ada Lovelace', 'ada@example.com', '+15551234567', publicKey]), ); }); - it("rejects invalid patch payloads", async () => { + it('rejects invalid patch payloads', async () => { const response = await request(app) - .patch("/user/profile") - .set("Authorization", `Bearer ${bearerToken()}`) - .send({ email: "not-an-email" }); + .patch('/user/profile') + .set('Authorization', `Bearer ${bearerToken()}`) + .send({ email: 'not-an-email' }); expect(response.status).toBe(400); - expect(response.body.message).toBe("Validation failed"); + expect(response.body.message).toBe('Validation failed'); expect(queryMock).not.toHaveBeenCalled(); }); - it("rejects unauthenticated requests", async () => { - const response = await request(app).get("/user/profile"); + it('rejects unauthenticated requests', async () => { + const response = await request(app).get('/user/profile'); expect(response.status).toBe(401); expect(queryMock).not.toHaveBeenCalled(); diff --git a/backend/src/__tests__/validation.test.ts b/backend/src/__tests__/validation.test.ts index 029bc0c8..823fd04b 100644 --- a/backend/src/__tests__/validation.test.ts +++ b/backend/src/__tests__/validation.test.ts @@ -6,37 +6,34 @@ import { generateJwtToken } from "../services/authService.js"; process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; // Setup mocks BEFORE importing the app -const mockQuery = - jest.fn<(...args: unknown[]) => Promise<{ rows: unknown[] }>>(); +const mockQuery = jest.fn<(...args: unknown[]) => Promise<{ rows: unknown[] }>>(); const mockRelease = jest.fn(); const mockClient = { query: mockQuery, release: mockRelease, }; -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, - getClient: jest - .fn<() => Promise>() - .mockResolvedValue(mockClient), + getClient: jest.fn<() => Promise>().mockResolvedValue(mockClient), closePool: jest.fn(), withTransaction: jest.fn(), })); // Mock CacheService to prevent Redis connections -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); // Use dynamic imports to ensure mocks are applied -await import("../db/connection.js"); -const { default: app } = await import("../app.js"); +await import('../db/connection.js'); +const { default: app } = await import('../app.js'); const TEST_WALLET = Keypair.random().publicKey(); const OTHER_WALLET = Keypair.random().publicKey(); diff --git a/backend/src/__tests__/version.test.ts b/backend/src/__tests__/version.test.ts index 15dfd313..e3fc592b 100644 --- a/backend/src/__tests__/version.test.ts +++ b/backend/src/__tests__/version.test.ts @@ -1,47 +1,43 @@ -import { jest } from "@jest/globals"; -import request from "supertest"; +import { jest } from '@jest/globals'; +import request from 'supertest'; // Must mock all app-level dependencies before importing app. -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { - query: jest - .fn<() => Promise>() - .mockResolvedValue({ rows: [], rowCount: 0 }), + query: jest.fn<() => Promise>().mockResolvedValue({ rows: [], rowCount: 0 }), }, - query: jest - .fn<() => Promise>() - .mockResolvedValue({ rows: [], rowCount: 0 }), + query: jest.fn<() => Promise>().mockResolvedValue({ rows: [], rowCount: 0 }), getClient: jest.fn(), withTransaction: jest.fn(), })); -jest.unstable_mockModule("../services/cacheService.js", () => ({ +jest.unstable_mockModule('../services/cacheService.js', () => ({ cacheService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -jest.unstable_mockModule("../services/sorobanService.js", () => ({ +jest.unstable_mockModule('../services/sorobanService.js', () => ({ sorobanService: { - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); -const { default: app } = await import("../app.js"); +const { default: app } = await import('../app.js'); -describe("GET /version", () => { +describe('GET /version', () => { const savedEnv: Record = {}; beforeEach(() => { // Snapshot and clear the build-time env vars so each test starts clean. for (const key of [ - "GIT_SHA", - "BUILD_TIME", - "LOAN_MANAGER_CONTRACT_ID", - "LENDING_POOL_CONTRACT_ID", - "REMITTANCE_NFT_CONTRACT_ID", - "MULTISIG_GOVERNANCE_CONTRACT_ID", + 'GIT_SHA', + 'BUILD_TIME', + 'LOAN_MANAGER_CONTRACT_ID', + 'LENDING_POOL_CONTRACT_ID', + 'REMITTANCE_NFT_CONTRACT_ID', + 'MULTISIG_GOVERNANCE_CONTRACT_ID', ]) { savedEnv[key] = process.env[key]; delete process.env[key]; @@ -59,61 +55,61 @@ describe("GET /version", () => { } }); - it("returns 200", async () => { - const res = await request(app).get("/version"); + it('returns 200', async () => { + const res = await request(app).get('/version'); expect(res.status).toBe(200); }); - it("response shape contains all required fields", async () => { - const res = await request(app).get("/version"); - expect(res.body).toHaveProperty("gitSha"); - expect(res.body).toHaveProperty("builtAt"); - expect(res.body).toHaveProperty("nodeVersion"); - expect(res.body).toHaveProperty("contracts"); - expect(res.body.contracts).toHaveProperty("loanManager"); - expect(res.body.contracts).toHaveProperty("lendingPool"); - expect(res.body.contracts).toHaveProperty("remittanceNft"); - expect(res.body.contracts).toHaveProperty("multisigGovernance"); + it('response shape contains all required fields', async () => { + const res = await request(app).get('/version'); + expect(res.body).toHaveProperty('gitSha'); + expect(res.body).toHaveProperty('builtAt'); + expect(res.body).toHaveProperty('nodeVersion'); + expect(res.body).toHaveProperty('contracts'); + expect(res.body.contracts).toHaveProperty('loanManager'); + expect(res.body.contracts).toHaveProperty('lendingPool'); + expect(res.body.contracts).toHaveProperty('remittanceNft'); + expect(res.body.contracts).toHaveProperty('multisigGovernance'); }); it("falls back to 'unknown' when GIT_SHA and BUILD_TIME are not set", async () => { - const res = await request(app).get("/version"); - expect(res.body.gitSha).toBe("unknown"); - expect(res.body.builtAt).toBe("unknown"); + const res = await request(app).get('/version'); + expect(res.body.gitSha).toBe('unknown'); + expect(res.body.builtAt).toBe('unknown'); }); - it("reflects GIT_SHA and BUILD_TIME env vars when set", async () => { - process.env.GIT_SHA = "abc1234def5678"; - process.env.BUILD_TIME = "2025-06-01T12:00:00Z"; + it('reflects GIT_SHA and BUILD_TIME env vars when set', async () => { + process.env.GIT_SHA = 'abc1234def5678'; + process.env.BUILD_TIME = '2025-06-01T12:00:00Z'; - const res = await request(app).get("/version"); - expect(res.body.gitSha).toBe("abc1234def5678"); - expect(res.body.builtAt).toBe("2025-06-01T12:00:00Z"); + const res = await request(app).get('/version'); + expect(res.body.gitSha).toBe('abc1234def5678'); + expect(res.body.builtAt).toBe('2025-06-01T12:00:00Z'); }); - it("reflects contract IDs from environment variables", async () => { - process.env.LOAN_MANAGER_CONTRACT_ID = "CLOAN"; - process.env.LENDING_POOL_CONTRACT_ID = "CPOOL"; - process.env.REMITTANCE_NFT_CONTRACT_ID = "CNFT"; - process.env.MULTISIG_GOVERNANCE_CONTRACT_ID = "CGOV"; - - const res = await request(app).get("/version"); - expect(res.body.contracts.loanManager).toBe("CLOAN"); - expect(res.body.contracts.lendingPool).toBe("CPOOL"); - expect(res.body.contracts.remittanceNft).toBe("CNFT"); - expect(res.body.contracts.multisigGovernance).toBe("CGOV"); + it('reflects contract IDs from environment variables', async () => { + process.env.LOAN_MANAGER_CONTRACT_ID = 'CLOAN'; + process.env.LENDING_POOL_CONTRACT_ID = 'CPOOL'; + process.env.REMITTANCE_NFT_CONTRACT_ID = 'CNFT'; + process.env.MULTISIG_GOVERNANCE_CONTRACT_ID = 'CGOV'; + + const res = await request(app).get('/version'); + expect(res.body.contracts.loanManager).toBe('CLOAN'); + expect(res.body.contracts.lendingPool).toBe('CPOOL'); + expect(res.body.contracts.remittanceNft).toBe('CNFT'); + expect(res.body.contracts.multisigGovernance).toBe('CGOV'); }); it("contract IDs fall back to 'unknown' when env vars are absent", async () => { - const res = await request(app).get("/version"); - expect(res.body.contracts.loanManager).toBe("unknown"); - expect(res.body.contracts.lendingPool).toBe("unknown"); - expect(res.body.contracts.remittanceNft).toBe("unknown"); - expect(res.body.contracts.multisigGovernance).toBe("unknown"); + const res = await request(app).get('/version'); + expect(res.body.contracts.loanManager).toBe('unknown'); + expect(res.body.contracts.lendingPool).toBe('unknown'); + expect(res.body.contracts.remittanceNft).toBe('unknown'); + expect(res.body.contracts.multisigGovernance).toBe('unknown'); }); - it("nodeVersion matches the running Node.js process", async () => { - const res = await request(app).get("/version"); + it('nodeVersion matches the running Node.js process', async () => { + const res = await request(app).get('/version'); expect(res.body.nodeVersion).toBe(process.version); }); }); diff --git a/backend/src/__tests__/webhookService.test.ts b/backend/src/__tests__/webhookService.test.ts index f3af5e1f..373a9369 100644 --- a/backend/src/__tests__/webhookService.test.ts +++ b/backend/src/__tests__/webhookService.test.ts @@ -1,4 +1,4 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; @@ -6,18 +6,17 @@ const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), closePool: jest.fn(), })); -const { WebhookService, getRetryDelayMs } = - await import("../services/webhookService.js"); -const { default: logger } = await import("../utils/logger.js"); +const { WebhookService, getRetryDelayMs } = await import('../services/webhookService.js'); +const { default: logger } = await import('../utils/logger.js'); -describe("WebhookService", () => { +describe('WebhookService', () => { const originalFetch = global.fetch; afterEach(() => { @@ -26,71 +25,66 @@ describe("WebhookService", () => { delete process.env.WEBHOOK_MAX_PAYLOAD_BYTES; }); - it("returns the expected retry delays", () => { + it('returns the expected retry delays', () => { expect(getRetryDelayMs(1)).toBe(5 * 60 * 1000); expect(getRetryDelayMs(2)).toBe(15 * 60 * 1000); expect(getRetryDelayMs(3)).toBe(45 * 60 * 1000); expect(getRetryDelayMs(4)).toBe(45 * 60 * 1000); }); - it("persists retry state when the initial delivery fails", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + it('persists retry state when the initial delivery fails', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: false, status: 503 }); global.fetch = fetchMock as unknown as typeof fetch; - const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-123", - eventType: "LoanApproved", + eventId: 'evt-123', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-123", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-123', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(mockQuery).toHaveBeenCalledTimes(2); expect(mockQuery).toHaveBeenNthCalledWith( 1, - expect.stringContaining("FROM webhook_subscriptions"), - [JSON.stringify(["LoanApproved"])], + expect.stringContaining('FROM webhook_subscriptions'), + [JSON.stringify(['LoanApproved'])], ); expect(mockQuery).toHaveBeenNthCalledWith( 2, - expect.stringContaining("INSERT INTO webhook_deliveries"), + expect.stringContaining('INSERT INTO webhook_deliveries'), [ 1, - "evt-123", - "LoanApproved", + 'evt-123', + 'LoanApproved', 503, - "Webhook returned status 503", + 'Webhook returned status 503', JSON.stringify({ - eventId: "evt-123", - eventType: "LoanApproved", + eventId: 'evt-123', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: "2025-01-01T00:00:00.000Z", - txHash: "tx-123", - contractId: "contract-123", + ledgerClosedAt: '2025-01-01T00:00:00.000Z', + txHash: 'tx-123', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }), new Date(1_700_000_000_000 + getRetryDelayMs(1)), ], @@ -99,52 +93,42 @@ describe("WebhookService", () => { nowSpy.mockRestore(); }); - it("truncates oversized webhook payloads before delivery", async () => { - process.env.WEBHOOK_MAX_PAYLOAD_BYTES = "200"; + it('truncates oversized webhook payloads before delivery', async () => { + process.env.WEBHOOK_MAX_PAYLOAD_BYTES = '200'; - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; - const warnSpy = jest - .spyOn(logger, "warn") - .mockImplementation(() => logger as typeof logger); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger as typeof logger); mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-oversized", - eventType: "LoanApproved", + eventId: 'evt-oversized', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-oversized", - contractId: "contract-123", - topics: ["LoanApproved", "42"], - value: "x".repeat(1_024), + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-oversized', + contractId: 'contract-123', + topics: ['LoanApproved', '42'], + value: 'x'.repeat(1_024), }); expect(fetchMock).toHaveBeenCalledTimes(1); const callOpts = fetchMock.mock.calls[0]![1] as RequestInit; const deliveredBody = String(callOpts.body); - const deliveredPayload = JSON.parse(deliveredBody) as Record< - string, - unknown - >; + const deliveredPayload = JSON.parse(deliveredBody) as Record; expect(deliveredPayload.truncated).toBe(true); - expect(deliveredPayload.reason).toBe("payload_too_large"); - expect(deliveredPayload.eventId).toBe("evt-oversized"); + expect(deliveredPayload.reason).toBe('payload_too_large'); + expect(deliveredPayload.eventId).toBe('evt-oversized'); expect(deliveredPayload.maxPayloadBytes).toBe(200); expect(Number(deliveredPayload.originalPayloadBytes)).toBeGreaterThan(200); expect(deliveredPayload.value).toBeUndefined(); @@ -152,51 +136,44 @@ describe("WebhookService", () => { const insertParams = mockQuery.mock.calls[1]![1] as unknown[]; expect(JSON.parse(String(insertParams[5]))).toEqual(deliveredPayload); expect(warnSpy).toHaveBeenCalledWith( - "Webhook payload exceeds size limit, sending summary payload", + 'Webhook payload exceeds size limit, sending summary payload', expect.objectContaining({ - eventId: "evt-oversized", - eventType: "LoanApproved", + eventId: 'evt-oversized', + eventType: 'LoanApproved', maxPayloadBytes: 200, }), ); }); - it("logs when a webhook payload approaches the configured size limit", async () => { - process.env.WEBHOOK_MAX_PAYLOAD_BYTES = "512"; + it('logs when a webhook payload approaches the configured size limit', async () => { + process.env.WEBHOOK_MAX_PAYLOAD_BYTES = '512'; - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; - const warnSpy = jest - .spyOn(logger, "warn") - .mockImplementation(() => logger as typeof logger); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger as typeof logger); const event = { - eventId: "evt-near-limit", - eventType: "LoanApproved" as const, + eventId: 'evt-near-limit', + eventType: 'LoanApproved' as const, loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-near-limit", - contractId: "contract-123", - topics: ["LoanApproved", "42"], - value: "", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-near-limit', + contractId: 'contract-123', + topics: ['LoanApproved', '42'], + value: '', }; while (Buffer.byteLength(JSON.stringify(event)) < 460) { - event.value += "x"; + event.value += 'x'; } mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); @@ -205,301 +182,258 @@ describe("WebhookService", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( - "Webhook payload is near size limit", + 'Webhook payload is near size limit', expect.objectContaining({ - eventId: "evt-near-limit", - eventType: "LoanApproved", + eventId: 'evt-near-limit', + eventType: 'LoanApproved', maxPayloadBytes: 512, }), ); }); - describe("HMAC signature", () => { - it("sets X-RemitLend-Signature with sha256= prefix for a known body+secret", async () => { - const secret = "test-secret-key"; + describe('HMAC signature', () => { + it('sets X-RemitLend-Signature with sha256= prefix for a known body+secret', async () => { + const secret = 'test-secret-key'; const knownBody = JSON.stringify({ - eventId: "evt-known", - eventType: "LoanApproved", + eventId: 'evt-known', + eventType: 'LoanApproved', }); - const crypto = await import("node:crypto"); - const expectedHex = crypto - .createHmac("sha256", secret) - .update(knownBody) - .digest("hex"); + const crypto = await import('node:crypto'); + const expectedHex = crypto.createHmac('sha256', secret).update(knownBody).digest('hex'); const expectedHeader = `sha256=${expectedHex}`; // Directly inspect the header value by spying on fetch const fetchMock = - jest.fn< - ( - _url: string, - opts: RequestInit, - ) => Promise<{ ok: boolean; status: number }> - >(); + jest.fn<(_url: string, opts: RequestInit) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockImplementation(async (_url: string, opts: RequestInit) => { const hdrs = opts.headers as Record; - expect(hdrs["x-remitlend-signature"]).toBe(expectedHeader); + expect(hdrs['x-remitlend-signature']).toBe(expectedHeader); return { ok: true, status: 200 }; }); global.fetch = fetchMock as unknown as typeof fetch; mockQuery .mockResolvedValueOnce({ - rows: [{ id: 1, callback_url: "https://consumer.example", secret }], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-hmac-test", - eventType: "LoanApproved" as const, + eventId: 'evt-hmac-test', + eventType: 'LoanApproved' as const, loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-hmac", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-hmac', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("header value starts with 'sha256=' and matches HMAC-SHA256 of the request body", async () => { - const secret = "another-secret"; - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + const secret = 'another-secret'; + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; mockQuery .mockResolvedValueOnce({ - rows: [{ id: 1, callback_url: "https://hook.example", secret }], + rows: [{ id: 1, callback_url: 'https://hook.example', secret }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-prefix-check", - eventType: "LoanRepaid" as const, + eventId: 'evt-prefix-check', + eventType: 'LoanRepaid' as const, loanId: 7, - address: "GBORROWER456", + address: 'GBORROWER456', ledger: 200, - ledgerClosedAt: new Date("2025-06-01T00:00:00.000Z"), - txHash: "tx-prefix", - contractId: "contract-456", + ledgerClosedAt: new Date('2025-06-01T00:00:00.000Z'), + txHash: 'tx-prefix', + contractId: 'contract-456', topics: [], - value: "xdr-val", + value: 'xdr-val', }); const callOpts = fetchMock.mock.calls[0]![1] as RequestInit; const hdrs = callOpts.headers as Record; - const sigHeader = hdrs["x-remitlend-signature"]; + const sigHeader = hdrs['x-remitlend-signature']; // Must start with the algorithm prefix expect(sigHeader).toMatch(/^sha256=[a-f0-9]{64}$/); // The hex part must equal HMAC-SHA256(secret, body) - const crypto = await import("node:crypto"); + const crypto = await import('node:crypto'); const sentBody = callOpts.body as string; - const expectedHex = crypto - .createHmac("sha256", secret) - .update(sentBody) - .digest("hex"); + const expectedHex = crypto.createHmac('sha256', secret).update(sentBody).digest('hex'); expect(sigHeader).toBe(`sha256=${expectedHex}`); }); - it("omits X-RemitLend-Signature when no secret is configured", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + it('omits X-RemitLend-Signature when no secret is configured', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://nosecret.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://nosecret.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-no-secret", - eventType: "LoanApproved" as const, + eventId: 'evt-no-secret', + eventType: 'LoanApproved' as const, loanId: 1, - address: "GBORROWER789", + address: 'GBORROWER789', ledger: 300, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-nosecret", - contractId: "contract-789", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-nosecret', + contractId: 'contract-789', topics: [], - value: "xdr", + value: 'xdr', }); const callOpts = fetchMock.mock.calls[0]![1] as RequestInit; const hdrs = callOpts.headers as Record; - expect(hdrs["x-remitlend-signature"]).toBeUndefined(); + expect(hdrs['x-remitlend-signature']).toBeUndefined(); }); }); - describe("Retry logic", () => { - it("retries delivery on 5xx response", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + describe('Retry logic', () => { + it('retries delivery on 5xx response', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: false, status: 503 }); global.fetch = fetchMock as unknown as typeof fetch; - const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-5xx", - eventType: "LoanApproved", + eventId: 'evt-5xx', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-5xx", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-5xx', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).toHaveBeenCalledTimes(1); const insertCall = mockQuery.mock.calls[1]! as [string, unknown[]]; - expect(insertCall[0]).toContain("INSERT INTO webhook_deliveries"); + expect(insertCall[0]).toContain('INSERT INTO webhook_deliveries'); const params = insertCall[1]; expect(params[3]!).toBe(503); // last_status_code - expect(params[4]!).toBe("Webhook returned status 503"); // last_error - expect(params[6]!).toEqual( - new Date(1_700_000_000_000 + getRetryDelayMs(1)), - ); // next_retry_at + expect(params[4]!).toBe('Webhook returned status 503'); // last_error + expect(params[6]!).toEqual(new Date(1_700_000_000_000 + getRetryDelayMs(1))); // next_retry_at nowSpy.mockRestore(); }); - it("does not retry delivery on 4xx response", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + it('does not retry delivery on 4xx response', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: false, status: 400 }); global.fetch = fetchMock as unknown as typeof fetch; - const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-4xx", - eventType: "LoanApproved", + eventId: 'evt-4xx', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-4xx", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-4xx', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).toHaveBeenCalledTimes(1); const insertCall = mockQuery.mock.calls[1]! as [string, unknown[]]; - expect(insertCall[0]).toContain("INSERT INTO webhook_deliveries"); + expect(insertCall[0]).toContain('INSERT INTO webhook_deliveries'); const params = insertCall[1]; expect(params[3]!).toBe(400); // last_status_code - expect(params[4]!).toBe("Webhook returned status 400"); // last_error + expect(params[4]!).toBe('Webhook returned status 400'); // last_error // 4xx errors still schedule retry in current implementation - expect(params[6]!).toEqual( - new Date(1_700_000_000_000 + getRetryDelayMs(1)), - ); // next_retry_at + expect(params[6]!).toEqual(new Date(1_700_000_000_000 + getRetryDelayMs(1))); // next_retry_at nowSpy.mockRestore(); }); - it("does not retry delivery on 4xx response", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + it('does not retry delivery on 4xx response', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: false, status: 400 }); global.fetch = fetchMock as unknown as typeof fetch; - const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-4xx", - eventType: "LoanApproved", + eventId: 'evt-4xx', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-4xx", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-4xx', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).toHaveBeenCalledTimes(1); const insertCall = mockQuery.mock.calls[1]! as [string, unknown[]]; - expect(insertCall[0]).toContain("INSERT INTO webhook_deliveries"); + expect(insertCall[0]).toContain('INSERT INTO webhook_deliveries'); const params = insertCall[1]; expect(params[3]).toBe(400); // last_status_code - expect(params[4]).toBe("Webhook returned status 400"); // last_error + expect(params[4]).toBe('Webhook returned status 400'); // last_error // 4xx errors still schedule retry in current implementation - expect(params[6]).toEqual( - new Date(1_700_000_000_000 + getRetryDelayMs(1)), - ); // next_retry_at + expect(params[6]).toEqual(new Date(1_700_000_000_000 + getRetryDelayMs(1))); // next_retry_at nowSpy.mockRestore(); }); }); - describe("Subscription filtering", () => { - it("sends event to all matching subscriptions", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + describe('Subscription filtering', () => { + it('sends event to all matching subscriptions', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; mockQuery .mockResolvedValueOnce({ rows: [ - { id: 1, callback_url: "https://consumer1.example", secret: null }, - { id: 2, callback_url: "https://consumer2.example", secret: null }, - { id: 3, callback_url: "https://consumer3.example", secret: null }, + { id: 1, callback_url: 'https://consumer1.example', secret: null }, + { id: 2, callback_url: 'https://consumer2.example', secret: null }, + { id: 3, callback_url: 'https://consumer3.example', secret: null }, ], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -508,29 +442,26 @@ describe("WebhookService", () => { const service = new WebhookService(); await service.dispatch({ - eventId: "evt-multi", - eventType: "LoanApproved", + eventId: 'evt-multi', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-multi", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-multi', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).toHaveBeenCalledTimes(3); - expect(fetchMock.mock.calls[0]![0]).toBe("https://consumer1.example"); - expect(fetchMock.mock.calls[1]![0]).toBe("https://consumer2.example"); - expect(fetchMock.mock.calls[2]![0]).toBe("https://consumer3.example"); + expect(fetchMock.mock.calls[0]![0]).toBe('https://consumer1.example'); + expect(fetchMock.mock.calls[1]![0]).toBe('https://consumer2.example'); + expect(fetchMock.mock.calls[2]![0]).toBe('https://consumer3.example'); }); - it("skips inactive subscriptions", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + it('skips inactive subscriptions', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; @@ -541,59 +472,53 @@ describe("WebhookService", () => { const service = new WebhookService(); await service.dispatch({ - eventId: "evt-inactive", - eventType: "LoanApproved", + eventId: 'evt-inactive', + eventType: 'LoanApproved', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-inactive", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-inactive', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); expect(fetchMock).not.toHaveBeenCalled(); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("WHERE is_active = true"), + expect.stringContaining('WHERE is_active = true'), expect.any(Array), ); }); - it("applies event type filter correctly", async () => { - const fetchMock = - jest.fn< - (...args: unknown[]) => Promise<{ ok: boolean; status: number }> - >(); + it('applies event type filter correctly', async () => { + const fetchMock = jest.fn<(...args: unknown[]) => Promise<{ ok: boolean; status: number }>>(); fetchMock.mockResolvedValue({ ok: true, status: 200 }); global.fetch = fetchMock as unknown as typeof fetch; mockQuery .mockResolvedValueOnce({ - rows: [ - { id: 1, callback_url: "https://consumer.example", secret: null }, - ], + rows: [{ id: 1, callback_url: 'https://consumer.example', secret: null }], }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }); const service = new WebhookService(); await service.dispatch({ - eventId: "evt-filter", - eventType: "LoanRepaid", + eventId: 'evt-filter', + eventType: 'LoanRepaid', loanId: 42, - address: "GBORROWER123", + address: 'GBORROWER123', ledger: 100, - ledgerClosedAt: new Date("2025-01-01T00:00:00.000Z"), - txHash: "tx-filter", - contractId: "contract-123", + ledgerClosedAt: new Date('2025-01-01T00:00:00.000Z'), + txHash: 'tx-filter', + contractId: 'contract-123', topics: [], - value: "value-xdr", + value: 'value-xdr', }); - expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("event_types @> $1::jsonb"), - [JSON.stringify(["LoanRepaid"])], - ); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('event_types @> $1::jsonb'), [ + JSON.stringify(['LoanRepaid']), + ]); expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/backend/src/app.ts b/backend/src/app.ts index 4a6f414b..bb525b76 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,65 +1,56 @@ -import express, { - type Request, - type Response, - type NextFunction, -} from "express"; -import cors from "cors"; -import compression from "compression"; -import helmet from "helmet"; -import dotenv from "dotenv"; -import { Sentry } from "./config/sentry.js"; -import { mountSwaggerDocs } from "./config/swagger.js"; +import express, { type Request, type Response, type NextFunction } from 'express'; +import cors from 'cors'; +import compression from 'compression'; +import helmet from 'helmet'; +import dotenv from 'dotenv'; +import { Sentry } from './config/sentry.js'; +import { mountSwaggerDocs } from './config/swagger.js'; dotenv.config(); -import pool from "./db/connection.js"; -import { cacheService } from "./services/cacheService.js"; -import { sorobanService } from "./services/sorobanService.js"; -import simulationRoutes from "./routes/simulationRoutes.js"; -import scoreRoutes from "./routes/scoreRoutes.js"; -import loanRoutes from "./routes/loanRoutes.js"; -import poolRoutes from "./routes/poolRoutes.js"; -import indexerRoutes from "./routes/indexerRoutes.js"; -import adminRoutes from "./routes/adminRoutes.js"; -import authRoutes from "./routes/authRoutes.js"; -import userRoutes from "./routes/userRoutes.js"; -import notificationsRoutes from "./routes/notificationsRoutes.js"; -import eventRoutes from "./routes/eventRoutes.js"; -import remittanceRoutes from "./routes/remittanceRoutes.js"; -import transactionRoutes from "./routes/transactionRoutes.js"; -import { requireApiKey } from "./middleware/auth.js"; -import { globalRateLimiter } from "./middleware/rateLimiter.js"; -import { errorHandler } from "./middleware/errorHandler.js"; -import { metricsHandler, metricsMiddleware } from "./middleware/metrics.js"; -import { requestLogger } from "./middleware/requestLogger.js"; -import { requestIdMiddleware } from "./middleware/requestId.js"; -import { asyncHandler } from "./utils/asyncHandler.js"; -import { AppError } from "./errors/AppError.js"; +import pool from './db/connection.js'; +import { cacheService } from './services/cacheService.js'; +import { sorobanService } from './services/sorobanService.js'; +import simulationRoutes from './routes/simulationRoutes.js'; +import scoreRoutes from './routes/scoreRoutes.js'; +import loanRoutes from './routes/loanRoutes.js'; +import poolRoutes from './routes/poolRoutes.js'; +import indexerRoutes from './routes/indexerRoutes.js'; +import adminRoutes from './routes/adminRoutes.js'; +import authRoutes from './routes/authRoutes.js'; +import userRoutes from './routes/userRoutes.js'; +import notificationsRoutes from './routes/notificationsRoutes.js'; +import eventRoutes from './routes/eventRoutes.js'; +import remittanceRoutes from './routes/remittanceRoutes.js'; +import transactionRoutes from './routes/transactionRoutes.js'; +import { requireApiKey } from './middleware/auth.js'; +import { globalRateLimiter } from './middleware/rateLimiter.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { metricsHandler, metricsMiddleware } from './middleware/metrics.js'; +import { requestLogger } from './middleware/requestLogger.js'; +import { requestIdMiddleware } from './middleware/requestId.js'; +import { asyncHandler } from './utils/asyncHandler.js'; +import { AppError } from './errors/AppError.js'; const app = express(); -const isProduction = process.env.NODE_ENV === "production"; +const isProduction = process.env.NODE_ENV === 'production'; const configuredFrontendUrl = process.env.FRONTEND_URL?.trim(); if (isProduction && !configuredFrontendUrl) { - throw new Error( - "FRONTEND_URL environment variable is required in production", - ); + throw new Error('FRONTEND_URL environment variable is required in production'); } // `CORS_ALLOWED_ORIGINS` is retained as a migration fallback while `FRONTEND_URL` // becomes the primary documented config for the frontend origin. const additionalAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS - ? process.env.CORS_ALLOWED_ORIGINS.split(",").map((origin) => origin.trim()) + ? process.env.CORS_ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()) : []; -const allowedOriginsList = [ - configuredFrontendUrl, - ...additionalAllowedOrigins, -].filter((origin): origin is string => Boolean(origin)); +const allowedOriginsList = [configuredFrontendUrl, ...additionalAllowedOrigins].filter( + (origin): origin is string => Boolean(origin), +); if (isProduction && allowedOriginsList.length === 0) { - throw new Error( - "No allowed origins configured for CORS in production. Set FRONTEND_URL.", - ); + throw new Error('No allowed origins configured for CORS in production. Set FRONTEND_URL.'); } const allowedOrigins = new Set(allowedOriginsList); @@ -100,16 +91,10 @@ const corsOptions: cors.CorsOptions = { return callback(null, true); } - return callback(AppError.forbidden("Origin is not allowed by CORS policy")); + return callback(AppError.forbidden('Origin is not allowed by CORS policy')); }, - methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allowedHeaders: [ - "Content-Type", - "Authorization", - "x-api-key", - "x-request-id", - "Idempotency-Key", - ], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-request-id', 'Idempotency-Key'], credentials: true, }; @@ -121,8 +106,8 @@ app.use(requestIdMiddleware); app.use(requestLogger); app.use(metricsMiddleware); -app.get("/", (req: Request, res: Response) => { - res.send("RemitLend Backend is running"); +app.get('/', (req: Request, res: Response) => { + res.send('RemitLend Backend is running'); }); /** @@ -136,52 +121,48 @@ app.get("/", (req: Request, res: Response) => { * GIT_SHA — full git commit SHA of the build (falls back to "unknown") * BUILD_TIME — ISO-8601 UTC timestamp of the build (falls back to "unknown") */ -app.get("/version", (_req: Request, res: Response) => { +app.get('/version', (_req: Request, res: Response) => { res.json({ - gitSha: process.env.GIT_SHA ?? "unknown", - builtAt: process.env.BUILD_TIME ?? "unknown", + gitSha: process.env.GIT_SHA ?? 'unknown', + builtAt: process.env.BUILD_TIME ?? 'unknown', nodeVersion: process.version, contracts: { - loanManager: process.env.LOAN_MANAGER_CONTRACT_ID ?? "unknown", - lendingPool: process.env.LENDING_POOL_CONTRACT_ID ?? "unknown", - remittanceNft: process.env.REMITTANCE_NFT_CONTRACT_ID ?? "unknown", - multisigGovernance: - process.env.MULTISIG_GOVERNANCE_CONTRACT_ID ?? "unknown", + loanManager: process.env.LOAN_MANAGER_CONTRACT_ID ?? 'unknown', + lendingPool: process.env.LENDING_POOL_CONTRACT_ID ?? 'unknown', + remittanceNft: process.env.REMITTANCE_NFT_CONTRACT_ID ?? 'unknown', + multisigGovernance: process.env.MULTISIG_GOVERNANCE_CONTRACT_ID ?? 'unknown', }, }); }); app.get( - "/health", + '/health', asyncHandler(async (_req: Request, res: Response) => { - const [databaseStatus, redisStatus, sorobanStatus] = - await Promise.allSettled([ - pool - .query("SELECT 1") - .then(() => "ok" as const) - .catch(() => "error" as const), - cacheService.ping(), - sorobanService.ping(), - ]); + const [databaseStatus, redisStatus, sorobanStatus] = await Promise.allSettled([ + pool + .query('SELECT 1') + .then(() => 'ok' as const) + .catch(() => 'error' as const), + cacheService.ping(), + sorobanService.ping(), + ]); const dbChecks = { - database: - databaseStatus.status === "fulfilled" ? databaseStatus.value : "error", - redis: redisStatus.status === "fulfilled" ? redisStatus.value : "error", + database: databaseStatus.status === 'fulfilled' ? databaseStatus.value : 'error', + redis: redisStatus.status === 'fulfilled' ? redisStatus.value : 'error', }; const checks = { - api: "ok" as const, + api: 'ok' as const, ...dbChecks, - soroban_rpc: - sorobanStatus.status === "fulfilled" ? sorobanStatus.value : "error", + soroban_rpc: sorobanStatus.status === 'fulfilled' ? sorobanStatus.value : 'error', }; - const coreOk = Object.values(dbChecks).every((c) => c === "ok"); - const allOk = coreOk && checks.soroban_rpc === "ok"; + const coreOk = Object.values(dbChecks).every((c) => c === 'ok'); + const allOk = coreOk && checks.soroban_rpc === 'ok'; res.status(coreOk ? 200 : 503).json({ - status: allOk ? "ok" : coreOk ? "degraded" : "down", + status: allOk ? 'ok' : coreOk ? 'degraded' : 'down', checks, uptime: process.uptime(), timestamp: Date.now(), @@ -189,7 +170,7 @@ app.get( }), ); -app.get("/metrics", requireApiKey("admin:indexer"), asyncHandler(metricsHandler)); +app.get('/metrics', requireApiKey('admin:indexer'), asyncHandler(metricsHandler)); /** * GET /health/deep @@ -198,88 +179,76 @@ app.get("/metrics", requireApiKey("admin:indexer"), asyncHandler(metricsHandler) * 200 with status "degraded" when indexer lag exceeds INDEXER_HEALTH_LAG_LIMIT. */ app.get( - "/health/deep", + '/health/deep', asyncHandler(async (_req: Request, res: Response) => { const TIMEOUT_MS = 2000; const INDEXER_HEALTH_LAG_LIMIT = Number.parseInt( - process.env.INDEXER_HEALTH_LAG_LIMIT ?? "100", + process.env.INDEXER_HEALTH_LAG_LIMIT ?? '100', 10, ); const withTimeout = (promise: Promise, fallback: T): Promise => Promise.race([ promise, - new Promise((resolve) => - setTimeout(() => resolve(fallback), TIMEOUT_MS), - ), + new Promise((resolve) => setTimeout(() => resolve(fallback), TIMEOUT_MS)), ]); - const [dbResult, redisResult, rpcResult, indexerResult] = - await Promise.allSettled([ - withTimeout( - pool - .query("SELECT 1") - .then(() => ({ status: "ok" as const })) - .catch(() => ({ status: "down" as const })), - { status: "down" as const }, - ), - withTimeout( - cacheService.ping().then((r) => ({ - status: r === "ok" ? ("ok" as const) : ("down" as const), - })), - { status: "down" as const }, - ), - withTimeout( - sorobanService.healthCheck().then((r) => ({ - status: r.connected ? ("ok" as const) : ("down" as const), - latestLedger: r.latestLedger, - })), - { status: "down" as const, latestLedger: undefined }, - ), - withTimeout( - pool - .query( - "SELECT last_indexed_ledger FROM indexer_state ORDER BY id DESC LIMIT 1", - ) - .then((r) => ({ - lastIndexedLedger: r.rows[0]?.last_indexed_ledger ?? null, - })) - .catch(() => ({ lastIndexedLedger: null })), - { lastIndexedLedger: null }, - ), - ]); - - const db = dbResult.status === "fulfilled" ? dbResult.value.status : "down"; - const redis = - redisResult.status === "fulfilled" ? redisResult.value.status : "down"; + const [dbResult, redisResult, rpcResult, indexerResult] = await Promise.allSettled([ + withTimeout( + pool + .query('SELECT 1') + .then(() => ({ status: 'ok' as const })) + .catch(() => ({ status: 'down' as const })), + { status: 'down' as const }, + ), + withTimeout( + cacheService.ping().then((r) => ({ + status: r === 'ok' ? ('ok' as const) : ('down' as const), + })), + { status: 'down' as const }, + ), + withTimeout( + sorobanService.healthCheck().then((r) => ({ + status: r.connected ? ('ok' as const) : ('down' as const), + latestLedger: r.latestLedger, + })), + { status: 'down' as const, latestLedger: undefined }, + ), + withTimeout( + pool + .query('SELECT last_indexed_ledger FROM indexer_state ORDER BY id DESC LIMIT 1') + .then((r) => ({ + lastIndexedLedger: r.rows[0]?.last_indexed_ledger ?? null, + })) + .catch(() => ({ lastIndexedLedger: null })), + { lastIndexedLedger: null }, + ), + ]); + + const db = dbResult.status === 'fulfilled' ? dbResult.value.status : 'down'; + const redis = redisResult.status === 'fulfilled' ? redisResult.value.status : 'down'; const rpcData = - rpcResult.status === "fulfilled" + rpcResult.status === 'fulfilled' ? rpcResult.value - : { status: "down" as const, latestLedger: undefined }; + : { status: 'down' as const, latestLedger: undefined }; const stellarRpc = rpcData.status; const rpcLedger = (rpcData as { latestLedger?: number }).latestLedger; const indexerData = - indexerResult.status === "fulfilled" - ? indexerResult.value - : { lastIndexedLedger: null }; + indexerResult.status === 'fulfilled' ? indexerResult.value : { lastIndexedLedger: null }; const lagLedgers = rpcLedger != null && indexerData.lastIndexedLedger != null ? rpcLedger - Number(indexerData.lastIndexedLedger) : null; const indexerStatus = lagLedgers === null - ? ("down" as const) + ? ('down' as const) : lagLedgers > INDEXER_HEALTH_LAG_LIMIT - ? ("degraded" as const) - : ("ok" as const); + ? ('degraded' as const) + : ('ok' as const); - const anyDown = db === "down" || redis === "down" || stellarRpc === "down"; - const overallStatus = anyDown - ? "down" - : indexerStatus === "degraded" - ? "degraded" - : "ok"; + const anyDown = db === 'down' || redis === 'down' || stellarRpc === 'down'; + const overallStatus = anyDown ? 'down' : indexerStatus === 'degraded' ? 'degraded' : 'ok'; res.status(anyDown ? 503 : 200).json({ status: overallStatus, @@ -298,54 +267,54 @@ app.get( ); // Legacy routes (deprecated, maintained for backward compatibility) -app.use("/api", simulationRoutes); -app.use("/api/score", scoreRoutes); -app.use("/api/loans", loanRoutes); -app.use("/api/pool", poolRoutes); -app.use("/api/indexer", indexerRoutes); -app.use("/api/admin", adminRoutes); -app.use("/api/auth", authRoutes); -app.use("/api/notifications", notificationsRoutes); -app.use("/api/events", eventRoutes); -app.use("/api/remittances", remittanceRoutes); -app.use("/api/transactions", transactionRoutes); +app.use('/api', simulationRoutes); +app.use('/api/score', scoreRoutes); +app.use('/api/loans', loanRoutes); +app.use('/api/pool', poolRoutes); +app.use('/api/indexer', indexerRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/auth', authRoutes); +app.use('/api/notifications', notificationsRoutes); +app.use('/api/events', eventRoutes); +app.use('/api/remittances', remittanceRoutes); +app.use('/api/transactions', transactionRoutes); // Versioned API routes (v1 - current) -app.use("/api/v1", simulationRoutes); -app.use("/api/v1/score", scoreRoutes); -app.use("/api/v1/loans", loanRoutes); -app.use("/api/v1/indexer", indexerRoutes); -app.use("/api/v1/admin", adminRoutes); -app.use("/api/v1/auth", authRoutes); -app.use("/api/v1/remittances", remittanceRoutes); -app.use("/api/v1/transactions", transactionRoutes); -app.use("/api/v1/pool", poolRoutes); -app.use("/api/v1/notifications", notificationsRoutes); -app.use("/api/v1/events", eventRoutes); -app.use("/user", userRoutes); +app.use('/api/v1', simulationRoutes); +app.use('/api/v1/score', scoreRoutes); +app.use('/api/v1/loans', loanRoutes); +app.use('/api/v1/indexer', indexerRoutes); +app.use('/api/v1/admin', adminRoutes); +app.use('/api/v1/auth', authRoutes); +app.use('/api/v1/remittances', remittanceRoutes); +app.use('/api/v1/transactions', transactionRoutes); +app.use('/api/v1/pool', poolRoutes); +app.use('/api/v1/notifications', notificationsRoutes); +app.use('/api/v1/events', eventRoutes); +app.use('/user', userRoutes); mountSwaggerDocs(app); // ── Diagnostic / Test Routes ───────────────────────────────────── // Only exposed in test environment to verify centralized error handling. -if (process.env.NODE_ENV === "test") { - app.get("/test/error/operational", () => { - throw AppError.badRequest("Diagnostic operational error"); +if (process.env.NODE_ENV === 'test') { + app.get('/test/error/operational', () => { + throw AppError.badRequest('Diagnostic operational error'); }); - app.get("/test/error/internal", () => { - throw AppError.internal("Diagnostic internal error"); + app.get('/test/error/internal', () => { + throw AppError.internal('Diagnostic internal error'); }); - app.get("/test/error/unexpected", () => { - throw new Error("Diagnostic unexpected exception"); + app.get('/test/error/unexpected', () => { + throw new Error('Diagnostic unexpected exception'); }); app.get( - "/test/error/async", + '/test/error/async', asyncHandler(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); - throw new Error("Diagnostic async exception"); + throw new Error('Diagnostic async exception'); }), ); } diff --git a/backend/src/auth/rbac.ts b/backend/src/auth/rbac.ts index 588237e0..74b9bd6a 100644 --- a/backend/src/auth/rbac.ts +++ b/backend/src/auth/rbac.ts @@ -1,8 +1,8 @@ -export const USER_ROLES = ["admin", "borrower", "lender"] as const; +export const USER_ROLES = ['admin', 'borrower', 'lender'] as const; export type UserRole = (typeof USER_ROLES)[number]; export const ROLE_SCOPES: Record = { - admin: ["admin:all"], + admin: ['admin:all'], borrower: [ "read:loans", "write:loans", @@ -20,7 +20,7 @@ const parseWalletSet = (wallets: string | undefined): Set => { return new Set( wallets - .split(",") + .split(',') .map((wallet) => wallet.trim()) .filter((wallet) => wallet.length > 0), ); @@ -29,20 +29,20 @@ const parseWalletSet = (wallets: string | undefined): Set => { export const resolveRoleForWallet = (publicKey: string): UserRole => { const adminWallets = parseWalletSet(process.env.ADMIN_WALLETS); if (adminWallets.has(publicKey)) { - return "admin"; + return 'admin'; } const lenderWallets = parseWalletSet(process.env.LENDER_WALLETS); if (lenderWallets.has(publicKey)) { - return "lender"; + return 'lender'; } - return "borrower"; + return 'borrower'; }; export const resolveScopesForRole = (role: UserRole): string[] => { const ownScopes = ROLE_SCOPES[role] ?? []; - if (role === "admin") { + if (role === 'admin') { return [...ownScopes]; } diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 6df53469..c6850987 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -1,4 +1,4 @@ -import logger from "../utils/logger.js"; +import logger from '../utils/logger.js'; /** * List of environment variables required for the application to function. @@ -6,22 +6,22 @@ import logger from "../utils/logger.js"; * with a clear error message. */ const REQUIRED_ENV_VARS = [ - "DATABASE_URL", - "REDIS_URL", - "JWT_SECRET", - "STELLAR_RPC_URL", - "STELLAR_NETWORK_PASSPHRASE", - "LOAN_MANAGER_CONTRACT_ID", - "LENDING_POOL_CONTRACT_ID", - "REMITTANCE_NFT_CONTRACT_ID", - "MULTISIG_GOVERNANCE_CONTRACT_ID", - "POOL_TOKEN_ADDRESS", - "LOAN_MANAGER_ADMIN_SECRET", - "INTERNAL_API_KEY", - "FRONTEND_URL", - "SCORE_DELTA_REPAY", - "SCORE_DELTA_DEFAULT", - "SCORE_DELTA_LATE", + 'DATABASE_URL', + 'REDIS_URL', + 'JWT_SECRET', + 'STELLAR_RPC_URL', + 'STELLAR_NETWORK_PASSPHRASE', + 'LOAN_MANAGER_CONTRACT_ID', + 'LENDING_POOL_CONTRACT_ID', + 'REMITTANCE_NFT_CONTRACT_ID', + 'MULTISIG_GOVERNANCE_CONTRACT_ID', + 'POOL_TOKEN_ADDRESS', + 'LOAN_MANAGER_ADMIN_SECRET', + 'INTERNAL_API_KEY', + 'FRONTEND_URL', + 'SCORE_DELTA_REPAY', + 'SCORE_DELTA_DEFAULT', + 'SCORE_DELTA_LATE', ]; /** @@ -31,22 +31,22 @@ const REQUIRED_ENV_VARS = [ export function validateEnvVars(): void { // Filter for variables that are either absent OR just whitespace const missing = REQUIRED_ENV_VARS.filter( - (key) => !process.env[key] || process.env[key]!.trim() === "", + (key) => !process.env[key] || process.env[key]!.trim() === '', ); if (missing.length > 0) { const boldRed = (msg: string) => `\x1b[1;31m${msg}\x1b[0m`; const bold = (msg: string) => `\x1b[1m${msg}\x1b[0m`; - const errorPrefix = boldRed("FATAL ERROR: Environment validation failed"); - const missingVarMsg = `Missing or empty required variables: ${bold(missing.join(", "))}`; + const errorPrefix = boldRed('FATAL ERROR: Environment validation failed'); + const missingVarMsg = `Missing or empty required variables: ${bold(missing.join(', '))}`; const actionMsg = `Please verify these variables in your \x1b[4m.env\x1b[0m file or deployment environment.`; // Direct console error for immediate visibility during startup failure console.error(`\n${errorPrefix}\n${missingVarMsg}\n${actionMsg}\n`); // Structured log for persistent logs (e.g., Sentry, CloudWatch, etc.) - logger.error("Environment validation failure", { + logger.error('Environment validation failure', { missing, node_env: process.env.NODE_ENV, }); @@ -55,5 +55,5 @@ export function validateEnvVars(): void { process.exit(1); } - logger.info("Environment variables validated successfully."); + logger.info('Environment variables validated successfully.'); } diff --git a/backend/src/config/loanConfig.ts b/backend/src/config/loanConfig.ts index df0b9beb..c7e948be 100644 --- a/backend/src/config/loanConfig.ts +++ b/backend/src/config/loanConfig.ts @@ -5,23 +5,19 @@ export interface LoanConfig { creditScoreThreshold: number; } -const LOAN_MIN_SCORE = "LOAN_MIN_SCORE"; -const LOAN_MAX_AMOUNT = "LOAN_MAX_AMOUNT"; -const LOAN_INTEREST_RATE_PERCENT = "LOAN_INTEREST_RATE_PERCENT"; -const CREDIT_SCORE_THRESHOLD = "CREDIT_SCORE_THRESHOLD"; +const LOAN_MIN_SCORE = 'LOAN_MIN_SCORE'; +const LOAN_MAX_AMOUNT = 'LOAN_MAX_AMOUNT'; +const LOAN_INTEREST_RATE_PERCENT = 'LOAN_INTEREST_RATE_PERCENT'; +const CREDIT_SCORE_THRESHOLD = 'CREDIT_SCORE_THRESHOLD'; const LOAN_MIN_SCORE_RANGE = { min: 300, max: 850 }; const LOAN_MAX_AMOUNT_RANGE = { min: 1, max: 1_000_000 }; // 0 is invalid as requested const INTEREST_RATE_PERCENT_RANGE = { min: 1, max: 100 }; const CREDIT_SCORE_THRESHOLD_RANGE = { min: 300, max: 850 }; -function parseRequiredInteger( - envKey: string, - min: number, - max: number, -): number { +function parseRequiredInteger(envKey: string, min: number, max: number): number { const rawValue = process.env[envKey]; - if (rawValue === undefined || rawValue.trim() === "") { + if (rawValue === undefined || rawValue.trim() === '') { throw new Error(`${envKey} is required but missing`); } @@ -32,9 +28,7 @@ function parseRequiredInteger( } if (parsed < min || parsed > max) { - throw new Error( - `${envKey} must be between ${min} and ${max} (inclusive), got ${parsed}`, - ); + throw new Error(`${envKey} must be between ${min} and ${max} (inclusive), got ${parsed}`); } return parsed; @@ -42,7 +36,7 @@ function parseRequiredInteger( function parseRequiredNumber(envKey: string, min: number, max: number): number { const rawValue = process.env[envKey]; - if (rawValue === undefined || rawValue.trim() === "") { + if (rawValue === undefined || rawValue.trim() === '') { throw new Error(`${envKey} is required but missing`); } @@ -53,9 +47,7 @@ function parseRequiredNumber(envKey: string, min: number, max: number): number { } if (parsed < min || parsed > max) { - throw new Error( - `${envKey} must be between ${min} and ${max} (inclusive), got ${parsed}`, - ); + throw new Error(`${envKey} must be between ${min} and ${max} (inclusive), got ${parsed}`); } return parsed; diff --git a/backend/src/config/sentry.ts b/backend/src/config/sentry.ts index a615a822..0cc34623 100644 --- a/backend/src/config/sentry.ts +++ b/backend/src/config/sentry.ts @@ -1,13 +1,13 @@ -import * as Sentry from "@sentry/node"; +import * as Sentry from '@sentry/node'; const SENTRY_DSN = process.env.SENTRY_DSN; -const NODE_ENV = process.env.NODE_ENV || "development"; +const NODE_ENV = process.env.NODE_ENV || 'development'; const ENVIRONMENT_MAP: Record = { - production: "production", - staging: "staging", - development: "development", - test: "test", + production: 'production', + staging: 'staging', + development: 'development', + test: 'test', }; export function initSentry(): void { @@ -19,9 +19,9 @@ export function initSentry(): void { dsn: SENTRY_DSN, environment: ENVIRONMENT_MAP[NODE_ENV] ?? NODE_ENV, // Only enable performance tracing in non-test environments - tracesSampleRate: NODE_ENV === "production" ? 0.2 : 1.0, + tracesSampleRate: NODE_ENV === 'production' ? 0.2 : 1.0, // Disable Sentry in test environment to avoid noise - enabled: NODE_ENV !== "test", + enabled: NODE_ENV !== 'test', }); } diff --git a/backend/src/config/stellar.ts b/backend/src/config/stellar.ts index 74e80813..3065aabf 100644 --- a/backend/src/config/stellar.ts +++ b/backend/src/config/stellar.ts @@ -1,6 +1,6 @@ -import { Networks, rpc } from "@stellar/stellar-sdk"; +import { Networks, rpc } from '@stellar/stellar-sdk'; -export type StellarNetwork = "testnet" | "mainnet"; +export type StellarNetwork = 'testnet' | 'mainnet'; interface StellarNetworkDefaults { rpcUrl: string; @@ -9,11 +9,11 @@ interface StellarNetworkDefaults { const STELLAR_DEFAULTS: Record = { testnet: { - rpcUrl: "https://soroban-testnet.stellar.org", + rpcUrl: 'https://soroban-testnet.stellar.org', passphrase: Networks.TESTNET, }, mainnet: { - rpcUrl: "https://soroban-mainnet.stellar.org", + rpcUrl: 'https://soroban-mainnet.stellar.org', passphrase: Networks.PUBLIC, }, }; @@ -25,14 +25,12 @@ export interface StellarConfig { } function parseNetwork(value: string | undefined): StellarNetwork { - const normalized = (value ?? "testnet").trim().toLowerCase(); - if (normalized === "testnet" || normalized === "mainnet") { + const normalized = (value ?? 'testnet').trim().toLowerCase(); + if (normalized === 'testnet' || normalized === 'mainnet') { return normalized; } - throw new Error( - `Invalid STELLAR_NETWORK "${value}". Expected "testnet" or "mainnet".`, - ); + throw new Error(`Invalid STELLAR_NETWORK "${value}". Expected "testnet" or "mainnet".`); } function ensureValidRpcUrl(value: string): void { @@ -40,24 +38,17 @@ function ensureValidRpcUrl(value: string): void { try { parsed = new URL(value); } catch { - throw new Error( - `Invalid STELLAR_RPC_URL "${value}". Expected a valid http(s) URL.`, - ); + throw new Error(`Invalid STELLAR_RPC_URL "${value}". Expected a valid http(s) URL.`); } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new Error( - `Invalid STELLAR_RPC_URL "${value}". Only http:// or https:// is supported.`, - ); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`Invalid STELLAR_RPC_URL "${value}". Only http:// or https:// is supported.`); } } -function ensureRpcUrlMatchesNetwork( - network: StellarNetwork, - rpcUrl: string, -): void { +function ensureRpcUrlMatchesNetwork(network: StellarNetwork, rpcUrl: string): void { const lower = rpcUrl.toLowerCase(); - const oppositeMarker = network === "testnet" ? "mainnet" : "testnet"; + const oppositeMarker = network === 'testnet' ? 'mainnet' : 'testnet'; if (lower.includes(oppositeMarker)) { throw new Error( @@ -66,10 +57,7 @@ function ensureRpcUrlMatchesNetwork( } } -function ensurePassphraseMatchesNetwork( - network: StellarNetwork, - passphrase: string, -): void { +function ensurePassphraseMatchesNetwork(network: StellarNetwork, passphrase: string): void { const expected = STELLAR_DEFAULTS[network].passphrase; if (passphrase !== expected) { throw new Error( @@ -84,16 +72,14 @@ export function getStellarConfig(): StellarConfig { const rpcUrl = (process.env.STELLAR_RPC_URL ?? defaults.rpcUrl).trim(); if (!rpcUrl) { - throw new Error("STELLAR_RPC_URL cannot be empty."); + throw new Error('STELLAR_RPC_URL cannot be empty.'); } ensureValidRpcUrl(rpcUrl); ensureRpcUrlMatchesNetwork(network, rpcUrl); - const networkPassphrase = ( - process.env.STELLAR_NETWORK_PASSPHRASE ?? defaults.passphrase - ).trim(); + const networkPassphrase = (process.env.STELLAR_NETWORK_PASSPHRASE ?? defaults.passphrase).trim(); if (!networkPassphrase) { - throw new Error("STELLAR_NETWORK_PASSPHRASE cannot be empty."); + throw new Error('STELLAR_NETWORK_PASSPHRASE cannot be empty.'); } ensurePassphraseMatchesNetwork(network, networkPassphrase); @@ -114,6 +100,6 @@ export function getStellarNetworkPassphrase(): string { export function createSorobanRpcServer(): rpc.Server { const rpcUrl = getStellarRpcUrl(); - const allowHttp = rpcUrl.startsWith("http://"); + const allowHttp = rpcUrl.startsWith('http://'); return new rpc.Server(rpcUrl, { allowHttp }); } diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 11df9351..33970480 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -1,14 +1,13 @@ -import path from "node:path"; -import type { Express, NextFunction, Request, Response } from "express"; -import { Router } from "express"; -import swaggerJSDoc from "swagger-jsdoc"; -import swaggerUi from "swagger-ui-express"; -import { swaggerSchemas } from "./swaggerSchemas.js"; +import path from 'node:path'; +import type { Express, NextFunction, Request, Response } from 'express'; +import { Router } from 'express'; +import swaggerJSDoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import { swaggerSchemas } from './swaggerSchemas.js'; export function isSwaggerEnabled(): boolean { return ( - process.env.NODE_ENV !== "production" || - process.env.ENABLE_SWAGGER?.toLowerCase() === "true" + process.env.NODE_ENV !== 'production' || process.env.ENABLE_SWAGGER?.toLowerCase() === 'true' ); } @@ -16,53 +15,52 @@ const cwd = process.cwd(); export const swaggerSpec = swaggerJSDoc({ definition: { - openapi: "3.0.0", + openapi: '3.0.0', info: { - title: "RemitLend API", - version: "1.0.0", - description: - "Backend API for RemitLend lending, scoring, remittance, and indexer flows.", + title: 'RemitLend API', + version: '1.0.0', + description: 'Backend API for RemitLend lending, scoring, remittance, and indexer flows.', }, servers: [ { - url: "/api", - description: "Legacy API base path", + url: '/api', + description: 'Legacy API base path', }, { - url: "/api/v1", - description: "Versioned API base path", + url: '/api/v1', + description: 'Versioned API base path', }, ], components: { securitySchemes: { ApiKeyAuth: { - type: "apiKey", - in: "header", - name: "x-api-key", + type: 'apiKey', + in: 'header', + name: 'x-api-key', }, BearerAuth: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', }, }, schemas: swaggerSchemas, }, }, apis: [ - path.join(cwd, "src/routes/**/*.{ts,js}"), - path.join(cwd, "src/controllers/**/*.{ts,js}"), - path.join(cwd, "dist/src/routes/**/*.js"), - path.join(cwd, "dist/src/controllers/**/*.js"), + path.join(cwd, 'src/routes/**/*.{ts,js}'), + path.join(cwd, 'src/controllers/**/*.{ts,js}'), + path.join(cwd, 'dist/src/routes/**/*.js'), + path.join(cwd, 'dist/src/controllers/**/*.js'), ], }); export function mountSwaggerDocs(app: Express): void { const docsRouter = Router(); docsRouter.use(...swaggerUi.serve); - docsRouter.get("/", swaggerUi.setup(swaggerSpec)); + docsRouter.get('/', swaggerUi.setup(swaggerSpec)); - app.use("/docs", (req: Request, res: Response, next: NextFunction) => { + app.use('/docs', (req: Request, res: Response, next: NextFunction) => { if (!isSwaggerEnabled()) { next(); return; @@ -77,7 +75,7 @@ export function mountSwaggerDocs(app: Express): void { docsRouter(req, res, next); }); - app.get("/docs.json", (req: Request, res: Response, next: NextFunction) => { + app.get('/docs.json', (req: Request, res: Response, next: NextFunction) => { if (!isSwaggerEnabled()) { next(); return; diff --git a/backend/src/config/swaggerSchemas.ts b/backend/src/config/swaggerSchemas.ts index 1641c177..1d10df1e 100644 --- a/backend/src/config/swaggerSchemas.ts +++ b/backend/src/config/swaggerSchemas.ts @@ -1,879 +1,841 @@ export const swaggerSchemas = { ValidationError: { - type: "object", + type: 'object', properties: { - path: { type: "string", example: "body.publicKey" }, - message: { type: "string", example: "Public key is required" }, + path: { type: 'string', example: 'body.publicKey' }, + message: { type: 'string', example: 'Public key is required' }, }, - required: ["path", "message"], + required: ['path', 'message'], }, ErrorResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: false }, - message: { type: "string", example: "Validation failed" }, + success: { type: 'boolean', example: false }, + message: { type: 'string', example: 'Validation failed' }, errors: { - type: "array", - items: { $ref: "#/components/schemas/ValidationError" }, + type: 'array', + items: { $ref: '#/components/schemas/ValidationError' }, }, - stack: { type: "string" }, + stack: { type: 'string' }, }, - required: ["success", "message"], + required: ['success', 'message'], }, SimpleSuccessResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, + success: { type: 'boolean', example: true }, }, - required: ["success"], + required: ['success'], }, SuccessMessageResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - message: { type: "string", example: "Webhook subscription deleted" }, + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Webhook subscription deleted' }, }, - required: ["success", "message"], + required: ['success', 'message'], }, ServerSentEventStream: { - type: "string", + type: 'string', example: 'data: {"type":"init"}\n\n', }, ChallengeMessage: { - type: "object", + type: 'object', properties: { message: { - type: "string", + type: 'string', example: - "Sign this message to authenticate with RemitLend.\n\nNonce: abc123\nTimestamp: 1700000000000\n\nThis request will expire in 5 minutes.", + 'Sign this message to authenticate with RemitLend.\n\nNonce: abc123\nTimestamp: 1700000000000\n\nThis request will expire in 5 minutes.', }, - nonce: { type: "string", example: "abc123def456" }, - timestamp: { type: "integer", example: 1700000000000 }, - expiresIn: { type: "integer", example: 300000 }, + nonce: { type: 'string', example: 'abc123def456' }, + timestamp: { type: 'integer', example: 1700000000000 }, + expiresIn: { type: 'integer', example: 300000 }, }, - required: ["message", "nonce", "timestamp", "expiresIn"], + required: ['message', 'nonce', 'timestamp', 'expiresIn'], }, AuthChallengeResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/ChallengeMessage" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/ChallengeMessage' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, AuthLoginData: { - type: "object", + type: 'object', properties: { - token: { type: "string" }, - publicKey: { type: "string" }, + token: { type: 'string' }, + publicKey: { type: 'string' }, }, - required: ["token", "publicKey"], + required: ['token', 'publicKey'], }, AuthLoginResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/AuthLoginData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/AuthLoginData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, AuthVerifyData: { - type: "object", + type: 'object', properties: { - publicKey: { type: "string", nullable: true }, + publicKey: { type: 'string', nullable: true }, role: { - type: "string", - enum: ["admin", "borrower", "lender"], + type: 'string', + enum: ['admin', 'borrower', 'lender'], nullable: true, }, scopes: { - type: "array", - items: { type: "string" }, + type: 'array', + items: { type: 'string' }, }, - valid: { type: "boolean", example: true }, + valid: { type: 'boolean', example: true }, }, - required: ["valid"], + required: ['valid'], }, AuthVerifyResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/AuthVerifyData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/AuthVerifyData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, BorrowerLoan: { - type: "object", - properties: { - loanId: { type: "integer" }, - principal: { type: "number" }, - accruedInterest: { type: "number" }, - totalRepaid: { type: "number" }, - totalOwed: { type: "number" }, - nextPaymentDeadline: { type: "string", format: "date-time" }, + type: 'object', + properties: { + loanId: { type: 'integer' }, + principal: { type: 'number' }, + accruedInterest: { type: 'number' }, + totalRepaid: { type: 'number' }, + totalOwed: { type: 'number' }, + nextPaymentDeadline: { type: 'string', format: 'date-time' }, status: { - type: "string", - enum: ["active", "repaid", "defaulted"], + type: 'string', + enum: ['active', 'repaid', 'defaulted'], }, - borrower: { type: "string" }, - approvedAt: { type: "string", format: "date-time", nullable: true }, + borrower: { type: 'string' }, + approvedAt: { type: 'string', format: 'date-time', nullable: true }, }, required: [ - "loanId", - "principal", - "accruedInterest", - "totalRepaid", - "totalOwed", - "nextPaymentDeadline", - "status", - "borrower", + 'loanId', + 'principal', + 'accruedInterest', + 'totalRepaid', + 'totalOwed', + 'nextPaymentDeadline', + 'status', + 'borrower', ], }, BorrowerLoansResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - borrower: { type: "string" }, + success: { type: 'boolean', example: true }, + borrower: { type: 'string' }, loans: { - type: "array", - items: { $ref: "#/components/schemas/BorrowerLoan" }, + type: 'array', + items: { $ref: '#/components/schemas/BorrowerLoan' }, }, }, - required: ["success", "borrower", "loans"], + required: ['success', 'borrower', 'loans'], }, LoanSummaryEvent: { - type: "object", + type: 'object', properties: { - type: { type: "string" }, - amount: { type: "string", nullable: true }, - timestamp: { type: "string", format: "date-time", nullable: true }, - tx: { type: "string", nullable: true }, + type: { type: 'string' }, + amount: { type: 'string', nullable: true }, + timestamp: { type: 'string', format: 'date-time', nullable: true }, + tx: { type: 'string', nullable: true }, }, - required: ["type"], + required: ['type'], }, LoanDetailsSummary: { - type: "object", - properties: { - principal: { type: "number" }, - accruedInterest: { type: "number" }, - totalRepaid: { type: "number" }, - totalOwed: { type: "number" }, - interestRate: { type: "number" }, - termLedgers: { type: "integer" }, - elapsedLedgers: { type: "integer" }, + type: 'object', + properties: { + principal: { type: 'number' }, + accruedInterest: { type: 'number' }, + totalRepaid: { type: 'number' }, + totalOwed: { type: 'number' }, + interestRate: { type: 'number' }, + termLedgers: { type: 'integer' }, + elapsedLedgers: { type: 'integer' }, status: { - type: "string", - enum: ["active", "repaid", "defaulted"], + type: 'string', + enum: ['active', 'repaid', 'defaulted'], }, - requestedAt: { type: "string", format: "date-time", nullable: true }, - approvedAt: { type: "string", format: "date-time", nullable: true }, + requestedAt: { type: 'string', format: 'date-time', nullable: true }, + approvedAt: { type: 'string', format: 'date-time', nullable: true }, events: { - type: "array", - items: { $ref: "#/components/schemas/LoanSummaryEvent" }, + type: 'array', + items: { $ref: '#/components/schemas/LoanSummaryEvent' }, }, }, required: [ - "principal", - "accruedInterest", - "totalRepaid", - "totalOwed", - "interestRate", - "termLedgers", - "elapsedLedgers", - "status", - "events", + 'principal', + 'accruedInterest', + 'totalRepaid', + 'totalOwed', + 'interestRate', + 'termLedgers', + 'elapsedLedgers', + 'status', + 'events', ], }, LoanDetailsResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - loanId: { type: "string" }, - summary: { $ref: "#/components/schemas/LoanDetailsSummary" }, + success: { type: 'boolean', example: true }, + loanId: { type: 'string' }, + summary: { $ref: '#/components/schemas/LoanDetailsSummary' }, }, - required: ["success", "loanId", "summary"], + required: ['success', 'loanId', 'summary'], }, UnsignedTransactionResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - unsignedTxXdr: { type: "string" }, - networkPassphrase: { type: "string" }, + success: { type: 'boolean', example: true }, + unsignedTxXdr: { type: 'string' }, + networkPassphrase: { type: 'string' }, }, - required: ["success", "unsignedTxXdr", "networkPassphrase"], + required: ['success', 'unsignedTxXdr', 'networkPassphrase'], }, RepayTransactionResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - loanId: { type: "integer" }, - unsignedTxXdr: { type: "string" }, - networkPassphrase: { type: "string" }, + success: { type: 'boolean', example: true }, + loanId: { type: 'integer' }, + unsignedTxXdr: { type: 'string' }, + networkPassphrase: { type: 'string' }, }, - required: ["success", "loanId", "unsignedTxXdr", "networkPassphrase"], + required: ['success', 'loanId', 'unsignedTxXdr', 'networkPassphrase'], }, SubmittedTransactionResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - txHash: { type: "string" }, - status: { type: "string" }, - resultXdr: { type: "string" }, + success: { type: 'boolean', example: true }, + txHash: { type: 'string' }, + status: { type: 'string' }, + resultXdr: { type: 'string' }, }, - required: ["success", "txHash", "status"], + required: ['success', 'txHash', 'status'], }, PoolStats: { - type: "object", + type: 'object', properties: { - totalDeposits: { type: "number" }, - totalOutstanding: { type: "number" }, - utilizationRate: { type: "number" }, - apy: { type: "number" }, - activeLoansCount: { type: "integer" }, + totalDeposits: { type: 'number' }, + totalOutstanding: { type: 'number' }, + utilizationRate: { type: 'number' }, + apy: { type: 'number' }, + activeLoansCount: { type: 'integer' }, }, - required: [ - "totalDeposits", - "totalOutstanding", - "utilizationRate", - "apy", - "activeLoansCount", - ], + required: ['totalDeposits', 'totalOutstanding', 'utilizationRate', 'apy', 'activeLoansCount'], }, PoolStatsResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/PoolStats" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/PoolStats' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, DepositorPortfolio: { - type: "object", + type: 'object', properties: { - address: { type: "string" }, - depositAmount: { type: "number" }, - sharePercent: { type: "number" }, - estimatedYield: { type: "number" }, - apy: { type: "number" }, - firstDepositAt: { type: "string", format: "date-time", nullable: true }, + address: { type: 'string' }, + depositAmount: { type: 'number' }, + sharePercent: { type: 'number' }, + estimatedYield: { type: 'number' }, + apy: { type: 'number' }, + firstDepositAt: { type: 'string', format: 'date-time', nullable: true }, }, - required: [ - "address", - "depositAmount", - "sharePercent", - "estimatedYield", - "apy", - ], + required: ['address', 'depositAmount', 'sharePercent', 'estimatedYield', 'apy'], }, DepositorPortfolioResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/DepositorPortfolio" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/DepositorPortfolio' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, UserScoreFactors: { - type: "object", + type: 'object', properties: { - repaymentHistory: { type: "string" }, - latePaymentPenalty: { type: "string" }, - range: { type: "string" }, + repaymentHistory: { type: 'string' }, + latePaymentPenalty: { type: 'string' }, + range: { type: 'string' }, }, - required: ["repaymentHistory", "latePaymentPenalty", "range"], + required: ['repaymentHistory', 'latePaymentPenalty', 'range'], }, UserScore: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - userId: { type: "string" }, - score: { type: "integer", example: 700 }, + success: { type: 'boolean', example: true }, + userId: { type: 'string' }, + score: { type: 'integer', example: 700 }, band: { - type: "string", - enum: ["Excellent", "Good", "Fair", "Poor"], + type: 'string', + enum: ['Excellent', 'Good', 'Fair', 'Poor'], }, - factors: { $ref: "#/components/schemas/UserScoreFactors" }, + factors: { $ref: '#/components/schemas/UserScoreFactors' }, }, - required: ["success", "userId", "score", "band", "factors"], + required: ['success', 'userId', 'score', 'band', 'factors'], }, ScoreUpdateResponse: { - type: "object", - properties: { - success: { type: "boolean", example: true }, - userId: { type: "string" }, - repaymentAmount: { type: "number" }, - onTime: { type: "boolean" }, - oldScore: { type: "integer" }, - delta: { type: "integer" }, - newScore: { type: "integer" }, + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + userId: { type: 'string' }, + repaymentAmount: { type: 'number' }, + onTime: { type: 'boolean' }, + oldScore: { type: 'integer' }, + delta: { type: 'integer' }, + newScore: { type: 'integer' }, band: { - type: "string", - enum: ["Excellent", "Good", "Fair", "Poor"], + type: 'string', + enum: ['Excellent', 'Good', 'Fair', 'Poor'], }, }, required: [ - "success", - "userId", - "repaymentAmount", - "onTime", - "oldScore", - "delta", - "newScore", - "band", + 'success', + 'userId', + 'repaymentAmount', + 'onTime', + 'oldScore', + 'delta', + 'newScore', + 'band', ], }, ScoreBreakdownMetrics: { - type: "object", + type: 'object', properties: { - totalLoans: { type: "integer" }, - repaidOnTime: { type: "integer" }, - repaidLate: { type: "integer" }, - defaulted: { type: "integer" }, - totalRepaid: { type: "number" }, - averageRepaymentTime: { type: "string" }, - longestStreak: { type: "integer" }, - currentStreak: { type: "integer" }, + totalLoans: { type: 'integer' }, + repaidOnTime: { type: 'integer' }, + repaidLate: { type: 'integer' }, + defaulted: { type: 'integer' }, + totalRepaid: { type: 'number' }, + averageRepaymentTime: { type: 'string' }, + longestStreak: { type: 'integer' }, + currentStreak: { type: 'integer' }, }, required: [ - "totalLoans", - "repaidOnTime", - "repaidLate", - "defaulted", - "totalRepaid", - "averageRepaymentTime", - "longestStreak", - "currentStreak", + 'totalLoans', + 'repaidOnTime', + 'repaidLate', + 'defaulted', + 'totalRepaid', + 'averageRepaymentTime', + 'longestStreak', + 'currentStreak', ], }, ScoreHistoryEntry: { - type: "object", + type: 'object', properties: { - date: { type: "string", nullable: true }, - score: { type: "integer" }, - event: { type: "string" }, + date: { type: 'string', nullable: true }, + score: { type: 'integer' }, + event: { type: 'string' }, }, - required: ["date", "score", "event"], + required: ['date', 'score', 'event'], }, ScoreBreakdownResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - userId: { type: "string" }, - score: { type: "integer" }, + success: { type: 'boolean', example: true }, + userId: { type: 'string' }, + score: { type: 'integer' }, band: { - type: "string", - enum: ["Excellent", "Good", "Fair", "Poor"], + type: 'string', + enum: ['Excellent', 'Good', 'Fair', 'Poor'], }, - breakdown: { $ref: "#/components/schemas/ScoreBreakdownMetrics" }, + breakdown: { $ref: '#/components/schemas/ScoreBreakdownMetrics' }, history: { - type: "array", - items: { $ref: "#/components/schemas/ScoreHistoryEntry" }, + type: 'array', + items: { $ref: '#/components/schemas/ScoreHistoryEntry' }, }, }, - required: ["success", "userId", "score", "band", "breakdown", "history"], + required: ['success', 'userId', 'score', 'band', 'breakdown', 'history'], }, RemittanceHistoryEntry: { - type: "object", + type: 'object', properties: { - month: { type: "string" }, - amount: { type: "number" }, - status: { type: "string", enum: ["Completed", "Defaulted"] }, + month: { type: 'string' }, + amount: { type: 'number' }, + status: { type: 'string', enum: ['Completed', 'Defaulted'] }, }, - required: ["month", "amount", "status"], + required: ['month', 'amount', 'status'], }, RemittanceHistory: { - type: "object", + type: 'object', properties: { - userId: { type: "string" }, - score: { type: "integer" }, - streak: { type: "integer" }, + userId: { type: 'string' }, + score: { type: 'integer' }, + streak: { type: 'integer' }, history: { - type: "array", - items: { $ref: "#/components/schemas/RemittanceHistoryEntry" }, + type: 'array', + items: { $ref: '#/components/schemas/RemittanceHistoryEntry' }, }, }, - required: ["userId", "score", "streak", "history"], + required: ['userId', 'score', 'streak', 'history'], }, SimulatePaymentResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - message: { type: "string" }, - newScore: { type: "integer" }, + success: { type: 'boolean', example: true }, + message: { type: 'string' }, + newScore: { type: 'integer' }, }, - required: ["success", "message", "newScore"], + required: ['success', 'message', 'newScore'], }, Notification: { - type: "object", + type: 'object', properties: { - id: { type: "integer" }, - userId: { type: "string" }, + id: { type: 'integer' }, + userId: { type: 'string' }, type: { - type: "string", + type: 'string', enum: [ - "loan_approved", - "repayment_due", - "repayment_confirmed", - "loan_defaulted", - "loan_liquidated", - "score_changed", + 'loan_approved', + 'repayment_due', + 'repayment_confirmed', + 'loan_defaulted', + 'loan_liquidated', + 'score_changed', ], }, - title: { type: "string" }, - message: { type: "string" }, - loanId: { type: "integer" }, - actionUrl: { type: "string", nullable: true }, - read: { type: "boolean" }, - createdAt: { type: "string", format: "date-time" }, + title: { type: 'string' }, + message: { type: 'string' }, + loanId: { type: 'integer' }, + actionUrl: { type: 'string', nullable: true }, + read: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, }, - required: ["id", "userId", "type", "title", "message", "read", "createdAt"], + required: ['id', 'userId', 'type', 'title', 'message', 'read', 'createdAt'], }, NotificationsData: { - type: "object", + type: 'object', properties: { notifications: { - type: "array", - items: { $ref: "#/components/schemas/Notification" }, + type: 'array', + items: { $ref: '#/components/schemas/Notification' }, }, - unreadCount: { type: "integer" }, + unreadCount: { type: 'integer' }, }, - required: ["notifications", "unreadCount"], + required: ['notifications', 'unreadCount'], }, NotificationsResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/NotificationsData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/NotificationsData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, NotificationPreferences: { - type: "object", + type: 'object', properties: { - emailEnabled: { type: "boolean", example: true }, - smsEnabled: { type: "boolean", example: false }, + emailEnabled: { type: 'boolean', example: true }, + smsEnabled: { type: 'boolean', example: false }, phone: { - type: "string", + type: 'string', nullable: true, - example: "+14155552671", + example: '+14155552671', }, perTypeOverrides: { - type: "object", - additionalProperties: { type: "boolean" }, + type: 'object', + additionalProperties: { type: 'boolean' }, }, }, - required: ["emailEnabled", "smsEnabled", "phone", "perTypeOverrides"], + required: ['emailEnabled', 'smsEnabled', 'phone', 'perTypeOverrides'], }, EventConnectionCounts: { - type: "object", + type: 'object', properties: { - borrower: { type: "integer" }, - admin: { type: "integer" }, - total: { type: "integer" }, + borrower: { type: 'integer' }, + admin: { type: 'integer' }, + total: { type: 'integer' }, }, - required: ["borrower", "admin", "total"], + required: ['borrower', 'admin', 'total'], }, EventStreamStatusResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/EventConnectionCounts" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/EventConnectionCounts' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, LoanEventRecord: { - type: "object", + type: 'object', properties: { - eventId: { type: "string" }, + eventId: { type: 'string' }, eventType: { - type: "string", + type: 'string', enum: [ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", - "Seized", - "Paused", - "Unpaused", - "MinScoreUpdated", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', + 'Seized', + 'Paused', + 'Unpaused', + 'MinScoreUpdated', ], }, - loanId: { type: "integer" }, - borrower: { type: "string" }, - amount: { type: "string" }, - ledger: { type: "integer" }, - ledgerClosedAt: { type: "string", format: "date-time" }, - txHash: { type: "string" }, - createdAt: { type: "string", format: "date-time" }, - interestRateBps: { type: "integer" }, - termLedgers: { type: "integer" }, - contractId: { type: "string" }, + loanId: { type: 'integer' }, + borrower: { type: 'string' }, + amount: { type: 'string' }, + ledger: { type: 'integer' }, + ledgerClosedAt: { type: 'string', format: 'date-time' }, + txHash: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + interestRateBps: { type: 'integer' }, + termLedgers: { type: 'integer' }, + contractId: { type: 'string' }, topics: { - type: "array", - items: { type: "string" }, + type: 'array', + items: { type: 'string' }, }, - value: { type: "string" }, + value: { type: 'string' }, }, - required: [ - "eventId", - "eventType", - "borrower", - "ledger", - "ledgerClosedAt", - "txHash", - ], + required: ['eventId', 'eventType', 'borrower', 'ledger', 'ledgerClosedAt', 'txHash'], }, Pagination: { - type: "object", + type: 'object', properties: { - total: { type: "integer" }, - limit: { type: "integer" }, - offset: { type: "integer" }, + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' }, }, - required: ["total", "limit", "offset"], + required: ['total', 'limit', 'offset'], }, BorrowerEventsData: { - type: "object", + type: 'object', properties: { events: { - type: "array", - items: { $ref: "#/components/schemas/LoanEventRecord" }, + type: 'array', + items: { $ref: '#/components/schemas/LoanEventRecord' }, }, - pagination: { $ref: "#/components/schemas/Pagination" }, + pagination: { $ref: '#/components/schemas/Pagination' }, }, - required: ["events", "pagination"], + required: ['events', 'pagination'], }, BorrowerEventsResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/BorrowerEventsData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/BorrowerEventsData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, LoanEventsData: { - type: "object", + type: 'object', properties: { - loanId: { type: "integer" }, + loanId: { type: 'integer' }, events: { - type: "array", - items: { $ref: "#/components/schemas/LoanEventRecord" }, + type: 'array', + items: { $ref: '#/components/schemas/LoanEventRecord' }, }, }, - required: ["loanId", "events"], + required: ['loanId', 'events'], }, LoanEventsResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/LoanEventsData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/LoanEventsData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, RecentEventsData: { - type: "object", + type: 'object', properties: { events: { - type: "array", - items: { $ref: "#/components/schemas/LoanEventRecord" }, + type: 'array', + items: { $ref: '#/components/schemas/LoanEventRecord' }, }, }, - required: ["events"], + required: ['events'], }, RecentEventsResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/RecentEventsData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/RecentEventsData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, IndexerStatusData: { - type: "object", + type: 'object', properties: { - lastIndexedLedger: { type: "integer" }, - lastIndexedCursor: { type: "string", nullable: true }, - lastUpdated: { type: "string", format: "date-time" }, - totalEvents: { type: "integer" }, + lastIndexedLedger: { type: 'integer' }, + lastIndexedCursor: { type: 'string', nullable: true }, + lastUpdated: { type: 'string', format: 'date-time' }, + totalEvents: { type: 'integer' }, eventsByType: { - type: "object", - additionalProperties: { type: "integer" }, + type: 'object', + additionalProperties: { type: 'integer' }, }, }, required: [ - "lastIndexedLedger", - "lastIndexedCursor", - "lastUpdated", - "totalEvents", - "eventsByType", + 'lastIndexedLedger', + 'lastIndexedCursor', + 'lastUpdated', + 'totalEvents', + 'eventsByType', ], }, IndexerStatusResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/IndexerStatusData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/IndexerStatusData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, WebhookSubscription: { - type: "object", + type: 'object', properties: { - id: { type: "integer" }, - callbackUrl: { type: "string", format: "uri" }, + id: { type: 'integer' }, + callbackUrl: { type: 'string', format: 'uri' }, eventTypes: { - type: "array", + type: 'array', items: { - type: "string", + type: 'string', enum: [ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", - "Seized", - "Paused", - "Unpaused", - "MinScoreUpdated", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', + 'Seized', + 'Paused', + 'Unpaused', + 'MinScoreUpdated', ], }, }, - secret: { type: "string" }, - isActive: { type: "boolean" }, - createdAt: { type: "string", format: "date-time" }, - updatedAt: { type: "string", format: "date-time" }, + secret: { type: 'string' }, + isActive: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, }, - required: [ - "id", - "callbackUrl", - "eventTypes", - "isActive", - "createdAt", - "updatedAt", - ], + required: ['id', 'callbackUrl', 'eventTypes', 'isActive', 'createdAt', 'updatedAt'], }, WebhookSubscriptionListData: { - type: "object", + type: 'object', properties: { subscriptions: { - type: "array", - items: { $ref: "#/components/schemas/WebhookSubscription" }, + type: 'array', + items: { $ref: '#/components/schemas/WebhookSubscription' }, }, }, - required: ["subscriptions"], + required: ['subscriptions'], }, WebhookSubscriptionListResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/WebhookSubscriptionListData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/WebhookSubscriptionListData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, WebhookSubscriptionData: { - type: "object", + type: 'object', properties: { - subscription: { $ref: "#/components/schemas/WebhookSubscription" }, + subscription: { $ref: '#/components/schemas/WebhookSubscription' }, }, - required: ["subscription"], + required: ['subscription'], }, WebhookSubscriptionResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/WebhookSubscriptionData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/WebhookSubscriptionData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, WebhookDelivery: { - type: "object", + type: 'object', properties: { - id: { type: "integer" }, - subscriptionId: { type: "integer" }, - eventId: { type: "string" }, + id: { type: 'integer' }, + subscriptionId: { type: 'integer' }, + eventId: { type: 'string' }, eventType: { - type: "string", + type: 'string', enum: [ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", - "Seized", - "Paused", - "Unpaused", - "MinScoreUpdated", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', + 'Seized', + 'Paused', + 'Unpaused', + 'MinScoreUpdated', ], }, - attemptCount: { type: "integer" }, - lastStatusCode: { type: "integer" }, - lastError: { type: "string" }, - deliveredAt: { type: "string", format: "date-time" }, - createdAt: { type: "string", format: "date-time" }, - updatedAt: { type: "string", format: "date-time" }, + attemptCount: { type: 'integer' }, + lastStatusCode: { type: 'integer' }, + lastError: { type: 'string' }, + deliveredAt: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, }, required: [ - "id", - "subscriptionId", - "eventId", - "eventType", - "attemptCount", - "createdAt", - "updatedAt", + 'id', + 'subscriptionId', + 'eventId', + 'eventType', + 'attemptCount', + 'createdAt', + 'updatedAt', ], }, WebhookDeliveriesData: { - type: "object", + type: 'object', properties: { - subscriptionId: { type: "integer" }, + subscriptionId: { type: 'integer' }, deliveries: { - type: "array", - items: { $ref: "#/components/schemas/WebhookDelivery" }, + type: 'array', + items: { $ref: '#/components/schemas/WebhookDelivery' }, }, }, - required: ["subscriptionId", "deliveries"], + required: ['subscriptionId', 'deliveries'], }, WebhookDeliveriesResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/WebhookDeliveriesData" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/WebhookDeliveriesData' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, ReindexResult: { - type: "object", + type: 'object', properties: { - fromLedger: { type: "integer" }, - toLedger: { type: "integer" }, - fetchedEvents: { type: "integer" }, - insertedEvents: { type: "integer" }, - lastProcessedLedger: { type: "integer" }, + fromLedger: { type: 'integer' }, + toLedger: { type: 'integer' }, + fetchedEvents: { type: 'integer' }, + insertedEvents: { type: 'integer' }, + lastProcessedLedger: { type: 'integer' }, }, - required: [ - "fromLedger", - "toLedger", - "fetchedEvents", - "insertedEvents", - "lastProcessedLedger", - ], + required: ['fromLedger', 'toLedger', 'fetchedEvents', 'insertedEvents', 'lastProcessedLedger'], }, ReindexResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/ReindexResult" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/ReindexResult' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, DefaultCheckBatchResult: { - type: "object", + type: 'object', properties: { loanIds: { - type: "array", + type: 'array', maxItems: 1000, - items: { type: "integer" }, + items: { type: 'integer' }, }, - txHash: { type: "string" }, - submitStatus: { type: "string" }, - txStatus: { type: "string" }, - error: { type: "string" }, + txHash: { type: 'string' }, + submitStatus: { type: 'string' }, + txStatus: { type: 'string' }, + error: { type: 'string' }, }, - required: ["loanIds"], + required: ['loanIds'], }, DefaultCheckRunResult: { - type: "object", - properties: { - runId: { type: "string" }, - currentLedger: { type: "integer" }, - termLedgers: { type: "integer" }, - overdueCount: { type: "integer" }, - oldestDueLedger: { type: "integer" }, - ledgersPastOldestDue: { type: "integer" }, + type: 'object', + properties: { + runId: { type: 'string' }, + currentLedger: { type: 'integer' }, + termLedgers: { type: 'integer' }, + overdueCount: { type: 'integer' }, + oldestDueLedger: { type: 'integer' }, + ledgersPastOldestDue: { type: 'integer' }, batches: { - type: "array", - items: { $ref: "#/components/schemas/DefaultCheckBatchResult" }, + type: 'array', + items: { $ref: '#/components/schemas/DefaultCheckBatchResult' }, }, }, - required: [ - "runId", - "currentLedger", - "termLedgers", - "overdueCount", - "batches", - ], + required: ['runId', 'currentLedger', 'termLedgers', 'overdueCount', 'batches'], }, DefaultCheckResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: true }, - data: { $ref: "#/components/schemas/DefaultCheckRunResult" }, + success: { type: 'boolean', example: true }, + data: { $ref: '#/components/schemas/DefaultCheckRunResult' }, }, - required: ["success", "data"], + required: ['success', 'data'], }, // Error handling schemas for structured error codes (Issue #371) ErrorCode: { - type: "string", + type: 'string', enum: [ - "INVALID_AMOUNT", - "INVALID_PUBLIC_KEY", - "INVALID_SIGNATURE", - "INVALID_CHALLENGE", - "MISSING_FIELD", - "VALIDATION_ERROR", - "UNAUTHORIZED", - "TOKEN_EXPIRED", - "TOKEN_INVALID", - "CHALLENGE_EXPIRED", - "FORBIDDEN", - "ACCESS_DENIED", - "BORROWER_MISMATCH", - "NOT_FOUND", - "LOAN_NOT_FOUND", - "USER_NOT_FOUND", - "POOL_NOT_FOUND", - "CONFLICT", - "DUPLICATE_REQUEST", - "RATE_LIMIT_EXCEEDED", - "INTERNAL_ERROR", - "DATABASE_ERROR", - "EXTERNAL_SERVICE_ERROR", - "BLOCKCHAIN_ERROR", - "INSUFFICIENT_BALANCE", - "LOAN_ALREADY_REPAID", - "LOAN_NOT_ACTIVE", - "INVALID_LOAN_ID", - "INVALID_TX_XDR", + 'INVALID_AMOUNT', + 'INVALID_PUBLIC_KEY', + 'INVALID_SIGNATURE', + 'INVALID_CHALLENGE', + 'MISSING_FIELD', + 'VALIDATION_ERROR', + 'UNAUTHORIZED', + 'TOKEN_EXPIRED', + 'TOKEN_INVALID', + 'CHALLENGE_EXPIRED', + 'FORBIDDEN', + 'ACCESS_DENIED', + 'BORROWER_MISMATCH', + 'NOT_FOUND', + 'LOAN_NOT_FOUND', + 'USER_NOT_FOUND', + 'POOL_NOT_FOUND', + 'CONFLICT', + 'DUPLICATE_REQUEST', + 'RATE_LIMIT_EXCEEDED', + 'INTERNAL_ERROR', + 'DATABASE_ERROR', + 'EXTERNAL_SERVICE_ERROR', + 'BLOCKCHAIN_ERROR', + 'INSUFFICIENT_BALANCE', + 'LOAN_ALREADY_REPAID', + 'LOAN_NOT_ACTIVE', + 'INVALID_LOAN_ID', + 'INVALID_TX_XDR', ], - description: "Machine-readable error code for programmatic handling", + description: 'Machine-readable error code for programmatic handling', }, ErrorField: { - type: "object", + type: 'object', properties: { - code: { $ref: "#/components/schemas/ErrorCode" }, - message: { type: "string", example: "Amount must be a positive number" }, + code: { $ref: '#/components/schemas/ErrorCode' }, + message: { type: 'string', example: 'Amount must be a positive number' }, field: { - type: "string", - example: "amount", - description: "The field that caused the error (if applicable)", + type: 'string', + example: 'amount', + description: 'The field that caused the error (if applicable)', }, details: { - type: "object", - description: "Additional error details (if applicable)", + type: 'object', + description: 'Additional error details (if applicable)', }, }, - required: ["code", "message"], + required: ['code', 'message'], }, StructuredErrorResponse: { - type: "object", + type: 'object', properties: { - success: { type: "boolean", example: false }, - message: { type: "string", example: "Validation failed" }, + success: { type: 'boolean', example: false }, + message: { type: 'string', example: 'Validation failed' }, errors: { - type: "array", - items: { $ref: "#/components/schemas/ValidationError" }, + type: 'array', + items: { $ref: '#/components/schemas/ValidationError' }, }, - error: { $ref: "#/components/schemas/ErrorField" }, - field: { type: "string" }, + error: { $ref: '#/components/schemas/ErrorField' }, + field: { type: 'string' }, }, - required: ["success", "error"], + required: ['success', 'error'], }, } as const; diff --git a/backend/src/controllers/adminDisputeController.ts b/backend/src/controllers/adminDisputeController.ts index bb78871a..248f7b92 100644 --- a/backend/src/controllers/adminDisputeController.ts +++ b/backend/src/controllers/adminDisputeController.ts @@ -1,14 +1,8 @@ -import { query } from "../db/connection.js"; -import { AppError } from "../errors/AppError.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { - notificationService, - type NotificationType, -} from "../services/notificationService.js"; -import { - parseCursorQueryParams, - createCursorPaginatedResponse, -} from "../utils/pagination.js"; +import { query } from '../db/connection.js'; +import { AppError } from '../errors/AppError.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { notificationService, type NotificationType } from '../services/notificationService.js'; +import { parseCursorQueryParams, createCursorPaginatedResponse } from '../utils/pagination.js'; /** * List all loan disputes for admin review with cursor-based pagination. @@ -16,21 +10,21 @@ import { */ export const listLoanDisputes = asyncHandler(async (req, res) => { const { limit, cursor, status } = parseCursorQueryParams(req); - const statusFilter = status ?? "open"; + const statusFilter = status ?? 'open'; if ( - statusFilter !== "open" && - statusFilter !== "resolved" && - statusFilter !== "rejected" && - statusFilter !== "all" + statusFilter !== 'open' && + statusFilter !== 'resolved' && + statusFilter !== 'rejected' && + statusFilter !== 'all' ) { - throw AppError.badRequest("Invalid status filter"); + throw AppError.badRequest('Invalid status filter'); } const values: unknown[] = []; - let whereClause = ""; + let whereClause = ''; - if (statusFilter !== "all") { + if (statusFilter !== 'all') { values.push(statusFilter); whereClause = `WHERE status = $${values.length}`; } @@ -81,7 +75,7 @@ export const getLoanDispute = asyncHandler(async (req, res) => { ); if (disputeResult.rows.length === 0) { - throw AppError.notFound("Dispute not found"); + throw AppError.notFound('Dispute not found'); } res.json({ success: true, dispute: disputeResult.rows[0] }); @@ -100,11 +94,11 @@ export const resolveLoanDispute = asyncHandler(async (req, res) => { adminNote?: string; }; - if (!["confirm", "reverse"].includes(action)) { - throw AppError.badRequest("Action must be confirm or reverse"); + if (!['confirm', 'reverse'].includes(action)) { + throw AppError.badRequest('Action must be confirm or reverse'); } if (!resolution || resolution.length < 5) { - throw AppError.badRequest("Resolution reason required"); + throw AppError.badRequest('Resolution reason required'); } // Get dispute and loan @@ -113,7 +107,7 @@ export const resolveLoanDispute = asyncHandler(async (req, res) => { [disputeId], ); if (disputeResult.rows.length === 0) { - throw AppError.notFound("Dispute not found or already resolved"); + throw AppError.notFound('Dispute not found or already resolved'); } const dispute = disputeResult.rows[0]; @@ -123,13 +117,13 @@ export const resolveLoanDispute = asyncHandler(async (req, res) => { [resolution, adminNote || null, disputeId], ); - if (action === "confirm") { + if (action === 'confirm') { // Leave loan as defaulted, optionally log event await query( `INSERT INTO contract_events (loan_id, address, event_type, amount, ledger, ledger_closed_at) VALUES ($1, $2, 'DefaultConfirmed', NULL, NULL, NOW())`, [dispute.loan_id, dispute.borrower], ); - } else if (action === "reverse") { + } else if (action === 'reverse') { // Insert event to mark loan as active again await query( `INSERT INTO contract_events (loan_id, address, event_type, amount, ledger, ledger_closed_at) VALUES ($1, $2, 'DefaultReversed', NULL, NULL, NOW())`, @@ -140,12 +134,11 @@ export const resolveLoanDispute = asyncHandler(async (req, res) => { // Notify borrower via notifications + SSE (and external email if enabled) try { const msg = `Your dispute for loan ${dispute.loan_id} has been resolved: ${resolution}`; - const type = - action === "reverse" ? "repayment_confirmed" : "loan_defaulted"; + const type = action === 'reverse' ? 'repayment_confirmed' : 'loan_defaulted'; await notificationService.createNotification({ userId: dispute.borrower, type: type as NotificationType, - title: "Dispute resolved", + title: 'Dispute resolved', message: msg, loanId: dispute.loan_id, }); @@ -154,7 +147,7 @@ export const resolveLoanDispute = asyncHandler(async (req, res) => { // notificationService already logs errors internally } - res.json({ success: true, message: "Dispute resolved." }); + res.json({ success: true, message: 'Dispute resolved.' }); }); /** @@ -170,22 +163,22 @@ export const rejectLoanDispute = asyncHandler(async (req, res) => { [disputeId], ); if (disputeResult.rows.length === 0) { - throw AppError.notFound("Dispute not found or already processed"); + throw AppError.notFound('Dispute not found or already processed'); } const dispute = disputeResult.rows[0]; await query( `UPDATE loan_disputes SET status = 'rejected', resolution = $1, resolved_at = NOW() WHERE id = $2`, - [admin_note ?? "rejected by admin", disputeId], + [admin_note ?? 'rejected by admin', disputeId], ); try { const msg = `Your dispute for loan ${dispute.loan_id} was rejected by admin.`; await notificationService.createNotification({ userId: dispute.borrower, - type: "loan_defaulted" as NotificationType, - title: "Dispute rejected", + type: 'loan_defaulted' as NotificationType, + title: 'Dispute rejected', message: admin_note ? `${msg} Note: ${admin_note}` : msg, loanId: dispute.loan_id, }); @@ -193,5 +186,5 @@ export const rejectLoanDispute = asyncHandler(async (req, res) => { // swallow } - res.json({ success: true, message: "Dispute rejected." }); + res.json({ success: true, message: 'Dispute rejected.' }); }); diff --git a/backend/src/controllers/adminGovernanceController.ts b/backend/src/controllers/adminGovernanceController.ts index 800f9f74..75d4e7d0 100644 --- a/backend/src/controllers/adminGovernanceController.ts +++ b/backend/src/controllers/adminGovernanceController.ts @@ -1,6 +1,6 @@ -import type { Request, Response } from "express"; -import { query } from "../db/connection.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; +import type { Request, Response } from 'express'; +import { query } from '../db/connection.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; type GovernanceRow = { proposal_id?: string; @@ -14,26 +14,21 @@ type GovernanceRow = { }; function parseSignersFromEnv() { - return (process.env.GOVERNANCE_SIGNERS ?? "") - .split(",") + return (process.env.GOVERNANCE_SIGNERS ?? '') + .split(',') .map((address) => address.trim()) .filter(Boolean) .map((address) => ({ address, approved: false })); } -export const getPendingGovernance = asyncHandler( - async (_req: Request, res: Response) => { - const currentAdmin = - process.env.GOVERNANCE_CURRENT_ADMIN ?? - process.env.ADMIN_PUBLIC_KEY ?? - null; - const targetContract = process.env.MULTISIG_GOVERNANCE_CONTRACT_ID ?? null; - const threshold = - Number.parseInt(process.env.GOVERNANCE_THRESHOLD ?? "0", 10) || 0; +export const getPendingGovernance = asyncHandler(async (_req: Request, res: Response) => { + const currentAdmin = process.env.GOVERNANCE_CURRENT_ADMIN ?? process.env.ADMIN_PUBLIC_KEY ?? null; + const targetContract = process.env.MULTISIG_GOVERNANCE_CONTRACT_ID ?? null; + const threshold = Number.parseInt(process.env.GOVERNANCE_THRESHOLD ?? '0', 10) || 0; - try { - const result = await query( - `SELECT + try { + const result = await query( + `SELECT proposal_id, proposed_admin, approval_count, @@ -44,40 +39,39 @@ export const getPendingGovernance = asyncHandler( approved FROM multisig_governance_pending ORDER BY proposal_id DESC`, - ); + ); - if (result.rows.length > 0) { - const first = result.rows[0] as GovernanceRow; - res.json({ - currentAdmin, - targetContract, - pendingProposal: { - id: first.proposal_id, - proposedAdmin: first.proposed_admin, - approvalCount: Number(first.approval_count ?? 0), - threshold: Number(first.threshold ?? threshold), - executableAt: first.executable_at, - expiresAt: first.expires_at, - signers: result.rows - .filter((row: GovernanceRow) => row.signer_address) - .map((row: GovernanceRow) => ({ - address: row.signer_address, - approved: Boolean(row.approved), - })), - }, - }); - return; - } - } catch { - // The contract-facing index table is optional in early deployments. + if (result.rows.length > 0) { + const first = result.rows[0] as GovernanceRow; + res.json({ + currentAdmin, + targetContract, + pendingProposal: { + id: first.proposal_id, + proposedAdmin: first.proposed_admin, + approvalCount: Number(first.approval_count ?? 0), + threshold: Number(first.threshold ?? threshold), + executableAt: first.executable_at, + expiresAt: first.expires_at, + signers: result.rows + .filter((row: GovernanceRow) => row.signer_address) + .map((row: GovernanceRow) => ({ + address: row.signer_address, + approved: Boolean(row.approved), + })), + }, + }); + return; } + } catch { + // The contract-facing index table is optional in early deployments. + } - res.json({ - currentAdmin, - targetContract, - pendingProposal: null, - signers: parseSignersFromEnv(), - threshold, - }); - }, -); + res.json({ + currentAdmin, + targetContract, + pendingProposal: null, + signers: parseSignersFromEnv(), + threshold, + }); +}); diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index c0a47fc2..8b64755d 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -3,19 +3,14 @@ * Registers a test user with email and password. Returns a fake JWT. */ // Only import types once at the top -import { - getAuditLogs, - type AuditLogFilters, -} from "../services/auditLogService.js"; -import type { Request, Response, NextFunction } from "express"; -import { asyncHandler } from "../utils/asyncHandler.js"; +import { getAuditLogs, type AuditLogFilters } from '../services/auditLogService.js'; +import type { Request, Response, NextFunction } from 'express'; +import { asyncHandler } from '../utils/asyncHandler.js'; export const registerTestUser = asyncHandler( async (req: Request, res: Response, _next: NextFunction) => { const { email, password } = req.body; if (!email || !password) { - res - .status(400) - .json({ success: false, message: "Email and password required" }); + res.status(400).json({ success: false, message: 'Email and password required' }); return; } // In real app, insert user into DB. For test, just return a fake token. @@ -24,8 +19,8 @@ export const registerTestUser = asyncHandler( res.json({ success: true, token }); }, ); -import { AppError } from "../errors/AppError.js"; -import { ErrorCode } from "../errors/errorCodes.js"; +import { AppError } from '../errors/AppError.js'; +import { ErrorCode } from '../errors/errorCodes.js'; import { generateChallenge, verifySignature, @@ -35,12 +30,8 @@ import { } from "../services/authService.js"; import logger from "../utils/logger.js"; -const logAuthFailure = ( - req: Request, - publicKey: string | undefined, - reason: string, -): void => { - logger.warn("Auth attempt failed", { +const logAuthFailure = (req: Request, publicKey: string | undefined, reason: string): void => { + logger.warn('Auth attempt failed', { ip: req.ip, publicKey, reason, @@ -52,28 +43,21 @@ const logAuthFailure = ( export const requestChallenge = (req: Request, res: Response): void => { const { publicKey } = req.body; - if (!publicKey || typeof publicKey !== "string") { - logAuthFailure(req, publicKey, "missing_public_key"); - throw AppError.badRequest( - "Public key is required", - ErrorCode.MISSING_FIELD, - "publicKey", - ); + if (!publicKey || typeof publicKey !== 'string') { + logAuthFailure(req, publicKey, 'missing_public_key'); + throw AppError.badRequest('Public key is required', ErrorCode.MISSING_FIELD, 'publicKey'); } let challenge; try { challenge = generateChallenge(publicKey); } catch (error) { - if ( - error instanceof Error && - error.message === "Invalid Stellar public key" - ) { - logAuthFailure(req, publicKey, "invalid_public_key"); + if (error instanceof Error && error.message === 'Invalid Stellar public key') { + logAuthFailure(req, publicKey, 'invalid_public_key'); throw AppError.badRequest( - "Invalid Stellar public key", + 'Invalid Stellar public key', ErrorCode.INVALID_PUBLIC_KEY, - "publicKey", + 'publicKey', ); } throw error; @@ -88,70 +72,49 @@ export const requestChallenge = (req: Request, res: Response): void => { export const login = (req: Request, res: Response): void => { const { publicKey, message, signature } = req.body; - if (!publicKey || typeof publicKey !== "string") { - logAuthFailure(req, publicKey, "missing_public_key"); - throw AppError.badRequest( - "Public key is required", - ErrorCode.MISSING_FIELD, - "publicKey", - ); + if (!publicKey || typeof publicKey !== 'string') { + logAuthFailure(req, publicKey, 'missing_public_key'); + throw AppError.badRequest('Public key is required', ErrorCode.MISSING_FIELD, 'publicKey'); } - if (!message || typeof message !== "string") { - logAuthFailure(req, publicKey, "missing_message"); - throw AppError.badRequest( - "Message is required", - ErrorCode.MISSING_FIELD, - "message", - ); + if (!message || typeof message !== 'string') { + logAuthFailure(req, publicKey, 'missing_message'); + throw AppError.badRequest('Message is required', ErrorCode.MISSING_FIELD, 'message'); } - if (!signature || typeof signature !== "string") { - logAuthFailure(req, publicKey, "missing_signature"); - throw AppError.badRequest( - "Signature is required", - ErrorCode.MISSING_FIELD, - "signature", - ); + if (!signature || typeof signature !== 'string') { + logAuthFailure(req, publicKey, 'missing_signature'); + throw AppError.badRequest('Signature is required', ErrorCode.MISSING_FIELD, 'signature'); } const timestampMatch = message.match(/Timestamp: (\d+)/); if (!timestampMatch) { - logAuthFailure(req, publicKey, "invalid_challenge_format"); - throw AppError.badRequest( - "Invalid challenge message format", - ErrorCode.INVALID_CHALLENGE, - ); + logAuthFailure(req, publicKey, 'invalid_challenge_format'); + throw AppError.badRequest('Invalid challenge message format', ErrorCode.INVALID_CHALLENGE); } const timestamp = parseInt(timestampMatch[1]!, 10); if (!verifyChallengeTimestamp(timestamp)) { - logAuthFailure(req, publicKey, "challenge_expired"); - throw AppError.unauthorized( - "Challenge has expired", - ErrorCode.CHALLENGE_EXPIRED, - ); + logAuthFailure(req, publicKey, 'challenge_expired'); + throw AppError.unauthorized('Challenge has expired', ErrorCode.CHALLENGE_EXPIRED); } const isValidSignature = verifySignature(publicKey, message, signature); if (!isValidSignature) { - logAuthFailure(req, publicKey, "invalid_signature"); - throw AppError.unauthorized( - "Invalid signature", - ErrorCode.INVALID_SIGNATURE, - ); + logAuthFailure(req, publicKey, 'invalid_signature'); + throw AppError.unauthorized('Invalid signature', ErrorCode.INVALID_SIGNATURE); } const token = generateJwtToken(publicKey); - const cookieName = process.env.JWT_COOKIE_NAME ?? "remitlend_jwt"; + const cookieName = process.env.JWT_COOKIE_NAME ?? 'remitlend_jwt'; // Set secure, HTTP-only cookie to avoid leaking tokens in URL query parameters // for EventSource (SSE) connections. res.cookie(cookieName, token, { httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - path: "/", + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', maxAge: 24 * 60 * 60 * 1000, // 24 hours }); @@ -164,11 +127,7 @@ export const login = (req: Request, res: Response): void => { }); }; -export async function listAuditLogs( - req: Request, - res: Response, - next: NextFunction, -) { +export async function listAuditLogs(req: Request, res: Response, next: NextFunction) { try { const result = await getAuditLogs({ actor: req.query.actor as string | undefined, @@ -177,7 +136,7 @@ export async function listAuditLogs( to: req.query.to as string | undefined, cursor: req.query.cursor as string | undefined, limit: Number(req.query.limit ?? 25), - withTotal: req.query.withTotal === "true", + withTotal: req.query.withTotal === 'true', } as AuditLogFilters); return res.json(result); diff --git a/backend/src/controllers/eventStreamController.ts b/backend/src/controllers/eventStreamController.ts index 5598ac4a..bec1aa17 100644 --- a/backend/src/controllers/eventStreamController.ts +++ b/backend/src/controllers/eventStreamController.ts @@ -1,28 +1,28 @@ -import type { Request, Response } from "express"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { query } from "../db/connection.js"; -import { AppError } from "../errors/AppError.js"; -import { eventStreamService } from "../services/eventStreamService.js"; -import logger from "../utils/logger.js"; +import type { Request, Response } from 'express'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { query } from '../db/connection.js'; +import { AppError } from '../errors/AppError.js'; +import { eventStreamService } from '../services/eventStreamService.js'; +import logger from '../utils/logger.js'; const REPLAY_LIMIT = 100; type DbEventRow = Record; const mapLoanEventRow = (row: DbEventRow) => ({ - eventId: String(row.event_id ?? ""), - eventType: String(row.event_type ?? ""), + eventId: String(row.event_id ?? ''), + eventType: String(row.event_type ?? ''), loanId: row.loan_id !== undefined ? Number(row.loan_id) : undefined, - address: String(row.address ?? row.borrower ?? ""), + address: String(row.address ?? row.borrower ?? ''), amount: row.amount !== undefined ? String(row.amount) : undefined, ledger: Number(row.ledger ?? 0), - ledgerClosedAt: String(row.ledger_closed_at ?? ""), - txHash: String(row.tx_hash ?? ""), + ledgerClosedAt: String(row.ledger_closed_at ?? ''), + txHash: String(row.tx_hash ?? ''), }); const parseLastEventId = (req: Request): string | null => { - const headerValue = req.headers["last-event-id"]; - if (typeof headerValue === "string" && headerValue.trim().length > 0) { + const headerValue = req.headers['last-event-id']; + if (typeof headerValue === 'string' && headerValue.trim().length > 0) { return headerValue.trim(); } @@ -40,50 +40,46 @@ const parseLastEventId = (req: Request): string | null => { * - With `?borrower=G...` — streams events for that specific borrower (requires JWT matching) * - Without `?borrower` — streams all events (requires API key for admin access) */ -export const streamEvents = asyncHandler( - async (req: Request, res: Response) => { - const requestedBorrower = - typeof req.query.borrower === "string" ? req.query.borrower : undefined; - const lastEventId = parseLastEventId(req); - const userKey = req.user?.publicKey; - const role = req.user?.role; - - if (!userKey) { - throw AppError.unauthorized("Authentication required"); - } +export const streamEvents = asyncHandler(async (req: Request, res: Response) => { + const requestedBorrower = typeof req.query.borrower === 'string' ? req.query.borrower : undefined; + const lastEventId = parseLastEventId(req); + const userKey = req.user?.publicKey; + const role = req.user?.role; + + if (!userKey) { + throw AppError.unauthorized('Authentication required'); + } - const isAdmin = role === "admin"; + const isAdmin = role === 'admin'; - if (!isAdmin && requestedBorrower && requestedBorrower !== userKey) { - throw AppError.forbidden( - "Borrowers can only subscribe to their own events", - ); - } + if (!isAdmin && requestedBorrower && requestedBorrower !== userKey) { + throw AppError.forbidden('Borrowers can only subscribe to their own events'); + } - const borrower = requestedBorrower ?? (isAdmin ? undefined : userKey); + const borrower = requestedBorrower ?? (isAdmin ? undefined : userKey); - if (!eventStreamService.canOpenConnection(userKey)) { - throw new AppError( - `Maximum of ${eventStreamService.getMaxConnectionsPerUser()} SSE connections allowed per user`, - 429, - ); - } + if (!eventStreamService.canOpenConnection(userKey)) { + throw new AppError( + `Maximum of ${eventStreamService.getMaxConnectionsPerUser()} SSE connections allowed per user`, + 429, + ); + } - // SSE headers - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("X-Accel-Buffering", "no"); - res.flushHeaders(); - - let unsubscribe: () => void; - - if (borrower) { - // Send replay events first (if Last-Event-ID is provided), otherwise - // send recent events for initial context. - try { - const replayEvents = await query( - `SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash + // SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + let unsubscribe: () => void; + + if (borrower) { + // Send replay events first (if Last-Event-ID is provided), otherwise + // send recent events for initial context. + try { + const replayEvents = await query( + `SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash FROM contract_events WHERE address = $1 AND ( @@ -92,32 +88,25 @@ export const streamEvents = asyncHandler( ) ORDER BY id ASC LIMIT $3`, - [borrower, lastEventId, REPLAY_LIMIT], - ); - - if (replayEvents.rows.length > 0) { - for (const row of replayEvents.rows) { - eventStreamService.sendEvent( - res, - mapLoanEventRow(row as DbEventRow), - ); - } - } else if (!lastEventId) { - res.write( - `event: init\ndata: ${JSON.stringify({ type: "init", replayed: 0 })}\n\n`, - ); + [borrower, lastEventId, REPLAY_LIMIT], + ); + + if (replayEvents.rows.length > 0) { + for (const row of replayEvents.rows) { + eventStreamService.sendEvent(res, mapLoanEventRow(row as DbEventRow)); } - } catch (err) { - logger - .withContext() - .error("SSE replay fetch error", { borrower, lastEventId, err }); + } else if (!lastEventId) { + res.write(`event: init\ndata: ${JSON.stringify({ type: 'init', replayed: 0 })}\n\n`); } + } catch (err) { + logger.withContext().error('SSE replay fetch error', { borrower, lastEventId, err }); + } - unsubscribe = eventStreamService.subscribeAddress(userKey, borrower, res); - } else { - try { - const replayEvents = await query( - `SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash + unsubscribe = eventStreamService.subscribeAddress(userKey, borrower, res); + } else { + try { + const replayEvents = await query( + `SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash FROM contract_events WHERE ( $1::text IS NULL @@ -125,43 +114,33 @@ export const streamEvents = asyncHandler( ) ORDER BY id ASC LIMIT $2`, - [lastEventId, REPLAY_LIMIT], - ); - - if (replayEvents.rows.length > 0) { - for (const row of replayEvents.rows) { - eventStreamService.sendEvent( - res, - mapLoanEventRow(row as DbEventRow), - ); - } + [lastEventId, REPLAY_LIMIT], + ); + + if (replayEvents.rows.length > 0) { + for (const row of replayEvents.rows) { + eventStreamService.sendEvent(res, mapLoanEventRow(row as DbEventRow)); } - } catch (err) { - logger - .withContext() - .error("SSE admin replay fetch error", { lastEventId, err }); } - - const counts = eventStreamService.getConnectionCount(); - res.write( - `event: init\ndata: ${JSON.stringify({ type: "init", connections: counts })}\n\n`, - ); - unsubscribe = eventStreamService.subscribeAll(userKey, res); + } catch (err) { + logger.withContext().error('SSE admin replay fetch error', { lastEventId, err }); } - req.on("close", unsubscribe); - req.on("error", unsubscribe); - }, -); + const counts = eventStreamService.getConnectionCount(); + res.write(`event: init\ndata: ${JSON.stringify({ type: 'init', connections: counts })}\n\n`); + unsubscribe = eventStreamService.subscribeAll(userKey, res); + } + + req.on('close', unsubscribe); + req.on('error', unsubscribe); +}); /** * GET /api/events/status * * Returns the current SSE connection counts (admin use). */ -export const getEventStreamStatus = asyncHandler( - async (_req: Request, res: Response) => { - const counts = eventStreamService.getConnectionCount(); - res.json({ success: true, data: counts }); - }, -); +export const getEventStreamStatus = asyncHandler(async (_req: Request, res: Response) => { + const counts = eventStreamService.getConnectionCount(); + res.json({ success: true, data: counts }); +}); diff --git a/backend/src/controllers/indexerController.ts b/backend/src/controllers/indexerController.ts index 84aadfd3..d7439045 100644 --- a/backend/src/controllers/indexerController.ts +++ b/backend/src/controllers/indexerController.ts @@ -1,23 +1,20 @@ -import type { Request, Response } from "express"; -import { xdr } from "@stellar/stellar-sdk"; -import { query } from "../db/connection.js"; -import { - EventIndexer, - type SorobanRawEvent, -} from "../services/eventIndexer.js"; -import { cacheService } from "../services/cacheService.js"; +import type { Request, Response } from 'express'; +import { xdr } from '@stellar/stellar-sdk'; +import { query } from '../db/connection.js'; +import { EventIndexer, type SorobanRawEvent } from '../services/eventIndexer.js'; +import { cacheService } from '../services/cacheService.js'; import { SUPPORTED_WEBHOOK_EVENT_TYPES, webhookService, type WebhookEventType, -} from "../services/webhookService.js"; +} from '../services/webhookService.js'; import { createCursorPaginatedResponse, parseCursorQueryParams, parseQueryParams, -} from "../utils/pagination.js"; -import { parseCappedLimit } from "../utils/queryHelpers.js"; -import logger from "../utils/logger.js"; +} from '../utils/pagination.js'; +import { parseCappedLimit } from '../utils/queryHelpers.js'; +import logger from '../utils/logger.js'; /** * Returns true if the hostname resolves to a private, loopback, or link-local @@ -25,10 +22,10 @@ import logger from "../utils/logger.js"; */ function isPrivateHost(hostname: string): boolean { // Strip IPv6 brackets - const host = hostname.replace(/^\[|\]$/g, ""); + const host = hostname.replace(/^\[|\]$/g, ''); // Loopback - if (host === "localhost" || host === "::1") return true; + if (host === 'localhost' || host === '::1') return true; if (/^127\./.test(host)) return true; // Link-local (169.254.x.x, fe80::) @@ -41,35 +38,28 @@ function isPrivateHost(hostname: string): boolean { if (/^192\.168\./.test(host)) return true; // AWS / GCP metadata endpoints - if (host === "169.254.169.254" || host === "metadata.google.internal") - return true; + if (host === '169.254.169.254' || host === 'metadata.google.internal') return true; // Catch-all for unqualified single-label hostnames (e.g. "internal", "db") - if (!host.includes(".") && host !== "::1") return true; + if (!host.includes('.') && host !== '::1') return true; return false; } -import { getStellarRpcUrl } from "../config/stellar.js"; +import { getStellarRpcUrl } from '../config/stellar.js'; -const buildEventFilters = ( - req: Request, - baseParams: unknown[], - initialWhereClause: string, -) => { +const buildEventFilters = (req: Request, baseParams: unknown[], initialWhereClause: string) => { const { status, dateRange, amountRange } = parseQueryParams(req); const params = [...baseParams]; let whereClause = initialWhereClause; const appendCondition = (condition: string) => { - whereClause += whereClause.includes("WHERE") - ? ` AND ${condition}` - : ` WHERE ${condition}`; + whereClause += whereClause.includes('WHERE') ? ` AND ${condition}` : ` WHERE ${condition}`; }; const requestedStatus = - status && status !== "all" + status && status !== 'all' ? status - : typeof req.query.eventType === "string" + : typeof req.query.eventType === 'string' ? req.query.eventType : null; @@ -80,38 +70,30 @@ const buildEventFilters = ( if (amountRange) { params.push(amountRange.min, amountRange.max); - appendCondition( - `CAST(amount AS NUMERIC) BETWEEN $${params.length - 1} AND $${params.length}`, - ); + appendCondition(`CAST(amount AS NUMERIC) BETWEEN $${params.length - 1} AND $${params.length}`); } if (dateRange) { params.push(dateRange.start.toISOString(), dateRange.end.toISOString()); - appendCondition( - `ledger_closed_at BETWEEN $${params.length - 1} AND $${params.length}`, - ); + appendCondition(`ledger_closed_at BETWEEN $${params.length - 1} AND $${params.length}`); } return { params, whereClause }; }; -const buildEventsCacheKey = ( - scope: string, - resourceId: string | number, - req: Request, -) => +const buildEventsCacheKey = (scope: string, resourceId: string | number, req: Request) => [ - "events", + 'events', scope, String(resourceId), - `limit:${req.query.limit ?? "default"}`, - `cursor:${req.query.cursor ?? "default"}`, - `offset:${req.query.offset ?? "default"}`, - `sort:${req.query.sort ?? "default"}`, - `status:${req.query.status ?? req.query.eventType ?? "all"}`, - `date:${req.query.date_range ?? "all"}`, - `amount:${req.query.amount_range ?? "all"}`, - ].join(":"); + `limit:${req.query.limit ?? 'default'}`, + `cursor:${req.query.cursor ?? 'default'}`, + `offset:${req.query.offset ?? 'default'}`, + `sort:${req.query.sort ?? 'default'}`, + `status:${req.query.status ?? req.query.eventType ?? 'all'}`, + `date:${req.query.date_range ?? 'all'}`, + `amount:${req.query.amount_range ?? 'all'}`, + ].join(':'); type QuarantineEventRow = { id: number; @@ -133,7 +115,7 @@ const buildIndexerFromConfig = (): EventIndexer => { ].filter((id): id is string => Boolean(id && id.trim().length > 0)); if (contractIds.length === 0) { - throw new Error("At least one indexer contract ID must be configured"); + throw new Error('At least one indexer contract ID must be configured'); } const rpcUrl = getStellarRpcUrl(); @@ -147,11 +129,9 @@ const buildIndexerFromConfig = (): EventIndexer => { }); }; -const decodeQuarantinedRawEvent = ( - row: QuarantineEventRow, -): SorobanRawEvent | null => { +const decodeQuarantinedRawEvent = (row: QuarantineEventRow): SorobanRawEvent | null => { const raw = row.raw_xdr; - if (!raw || typeof raw !== "object") { + if (!raw || typeof raw !== 'object') { return null; } @@ -165,27 +145,21 @@ const decodeQuarantinedRawEvent = ( contractId?: unknown; }; - if (!Array.isArray(candidate.topics) || typeof candidate.value !== "string") { + if (!Array.isArray(candidate.topics) || typeof candidate.value !== 'string') { return null; } - const topics = candidate.topics.filter( - (topic): topic is string => typeof topic === "string", - ); + const topics = candidate.topics.filter((topic): topic is string => typeof topic === 'string'); if (topics.length !== candidate.topics.length) { return null; } try { - const topicValues = topics.map((topic) => - xdr.ScVal.fromXDR(topic, "base64"), - ); - const value = xdr.ScVal.fromXDR(candidate.value, "base64"); + const topicValues = topics.map((topic) => xdr.ScVal.fromXDR(topic, 'base64')); + const value = xdr.ScVal.fromXDR(candidate.value, 'base64'); const ledgerClosedAt = - typeof candidate.ledgerClosedAt === "string" - ? candidate.ledgerClosedAt - : row.quarantined_at; + typeof candidate.ledgerClosedAt === 'string' ? candidate.ledgerClosedAt : row.quarantined_at; return { id: row.event_id, @@ -194,15 +168,11 @@ const decodeQuarantinedRawEvent = ( value, ledger: row.ledger, ledgerClosedAt, - txHash: - typeof candidate.txHash === "string" ? candidate.txHash : row.tx_hash, - contractId: - typeof candidate.contractId === "string" - ? candidate.contractId - : row.contract_id, + txHash: typeof candidate.txHash === 'string' ? candidate.txHash : row.tx_hash, + contractId: typeof candidate.contractId === 'string' ? candidate.contractId : row.contract_id, }; } catch (error) { - logger.withContext().warn("Failed to decode quarantined raw event", { + logger.withContext().warn('Failed to decode quarantined raw event', { quarantineId: row.id, eventId: row.event_id, error, @@ -217,14 +187,14 @@ const decodeQuarantinedRawEvent = ( export const getIndexerStatus = async (req: Request, res: Response) => { try { const result = await query( - "SELECT last_indexed_ledger, last_indexed_cursor, updated_at FROM indexer_state ORDER BY id DESC LIMIT 1", + 'SELECT last_indexed_ledger, last_indexed_cursor, updated_at FROM indexer_state ORDER BY id DESC LIMIT 1', [], ); if (result.rows.length === 0) { return res.status(404).json({ success: false, - message: "Indexer state not found", + message: 'Indexer state not found', }); } @@ -235,10 +205,7 @@ export const getIndexerStatus = async (req: Request, res: Response) => { GROUP BY event_type`, [], ); - const totalEvents = await query( - "SELECT COUNT(*) as total FROM contract_events", - [], - ); + const totalEvents = await query('SELECT COUNT(*) as total FROM contract_events', []); res.json({ success: true, @@ -257,10 +224,10 @@ export const getIndexerStatus = async (req: Request, res: Response) => { }, }); } catch (error) { - logger.withContext().error("Failed to get indexer status", { error }); + logger.withContext().error('Failed to get indexer status', { error }); res.status(500).json({ success: false, - message: "Failed to get indexer status", + message: 'Failed to get indexer status', }); } }; @@ -271,18 +238,16 @@ export const getIndexerStatus = async (req: Request, res: Response) => { export const getBorrowerEvents = async (req: Request, res: Response) => { try { const borrowerParam = req.params.borrower; - const borrower = Array.isArray(borrowerParam) - ? borrowerParam[0] - : borrowerParam; + const borrower = Array.isArray(borrowerParam) ? borrowerParam[0] : borrowerParam; if (!borrower) { return res.status(400).json({ success: false, - message: "Borrower is required", + message: 'Borrower is required', }); } const { limit, cursor } = parseCursorQueryParams(req); - const cacheKey = buildEventsCacheKey("borrower", borrower, req); + const cacheKey = buildEventsCacheKey('borrower', borrower, req); const cachedData = await cacheService.get(cacheKey); if (cachedData) { @@ -290,17 +255,13 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { return; } - const { params, whereClause } = buildEventFilters( - req, - [borrower], - "WHERE address = $1", - ); - logger.debug("getBorrowerEvents after filters", { + const { params, whereClause } = buildEventFilters(req, [borrower], 'WHERE address = $1'); + logger.debug('getBorrowerEvents after filters', { params, whereClause, }); const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; - const cursorClause = `${whereClause.trim().length ? "AND" : "WHERE"} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; + const cursorClause = `${whereClause.trim().length ? 'AND' : 'WHERE'} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; const queryText = ` SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash, created_at, id @@ -310,20 +271,17 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { ORDER BY id ASC LIMIT $${params.length + 2} `; - logger.debug("getBorrowerEvents query", { + logger.debug('getBorrowerEvents query', { queryText, queryParams: [...params, cursorValue, limit + 1], }); const [result, totalCount] = await Promise.all([ query(queryText, [...params, cursorValue, limit + 1]), - query( - `SELECT COUNT(*) as count FROM contract_events ${whereClause}`, - params, - ), + query(`SELECT COUNT(*) as count FROM contract_events ${whereClause}`, params), ]); - logger.debug("getBorrowerEvents after query", { result, totalCount }); + logger.debug('getBorrowerEvents after query', { result, totalCount }); const hasNext = result.rows.length > limit; const events = hasNext ? result.rows.slice(0, limit) : result.rows; const lastEvent = events.length > 0 ? events[events.length - 1] : undefined; @@ -344,10 +302,10 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { await cacheService.set(cacheKey, response, 300); res.json(response); } catch (error) { - logger.withContext().error("Failed to get borrower events", { error }); + logger.withContext().error('Failed to get borrower events', { error }); res.status(500).json({ success: false, - message: "Failed to get borrower events", + message: 'Failed to get borrower events', }); } }; @@ -364,11 +322,11 @@ export const getLoanEvents = async (req: Request, res: Response) => { if (!loanId) { return res.status(400).json({ success: false, - message: "Loan ID is required", + message: 'Loan ID is required', }); } - const cacheKey = buildEventsCacheKey("loan", loanId as string, req); + const cacheKey = buildEventsCacheKey('loan', loanId as string, req); const cachedData = await cacheService.get(cacheKey); if (cachedData) { @@ -376,13 +334,9 @@ export const getLoanEvents = async (req: Request, res: Response) => { return; } - const { params, whereClause } = buildEventFilters( - req, - [loanId], - "WHERE loan_id = $1", - ); + const { params, whereClause } = buildEventFilters(req, [loanId], 'WHERE loan_id = $1'); const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; - const cursorClause = `${whereClause.trim().length ? "AND" : "WHERE"} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; + const cursorClause = `${whereClause.trim().length ? 'AND' : 'WHERE'} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; const queryText = ` SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash, created_at, id @@ -395,10 +349,7 @@ export const getLoanEvents = async (req: Request, res: Response) => { const [result, totalCount] = await Promise.all([ query(queryText, [...params, cursorValue, limit + 1]), - query( - `SELECT COUNT(*) as count FROM contract_events ${whereClause}`, - params, - ), + query(`SELECT COUNT(*) as count FROM contract_events ${whereClause}`, params), ]); const hasNext = result.rows.length > limit; @@ -421,10 +372,10 @@ export const getLoanEvents = async (req: Request, res: Response) => { await cacheService.set(cacheKey, response, 300); res.json(response); } catch (error) { - logger.withContext().error("Failed to get loan events", { error }); + logger.withContext().error('Failed to get loan events', { error }); res.status(500).json({ success: false, - message: "Failed to get loan events", + message: 'Failed to get loan events', }); } }; @@ -435,7 +386,7 @@ export const getLoanEvents = async (req: Request, res: Response) => { export const getRecentEvents = async (req: Request, res: Response) => { try { const { limit, cursor } = parseCursorQueryParams(req); - const cacheKey = buildEventsCacheKey("recent", "all", req); + const cacheKey = buildEventsCacheKey('recent', 'all', req); const cachedData = await cacheService.get(cacheKey); if (cachedData) { @@ -443,9 +394,9 @@ export const getRecentEvents = async (req: Request, res: Response) => { return; } - const { params, whereClause } = buildEventFilters(req, [], ""); + const { params, whereClause } = buildEventFilters(req, [], ''); const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; - const cursorClause = `${whereClause.trim().length ? "AND" : "WHERE"} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; + const cursorClause = `${whereClause.trim().length ? 'AND' : 'WHERE'} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; const queryText = ` SELECT event_id, event_type, loan_id, address, amount, ledger, ledger_closed_at, tx_hash, created_at, id @@ -458,13 +409,10 @@ export const getRecentEvents = async (req: Request, res: Response) => { const [result, totalCount] = await Promise.all([ query(queryText, [...params, cursorValue, limit + 1]), - query( - `SELECT COUNT(*) as count FROM contract_events ${whereClause}`, - params, - ), + query(`SELECT COUNT(*) as count FROM contract_events ${whereClause}`, params), ]); - logger.debug("getRecentEvents", { + logger.debug('getRecentEvents', { queryResult: result.rows, countResult: totalCount.rows, }); @@ -487,18 +435,15 @@ export const getRecentEvents = async (req: Request, res: Response) => { await cacheService.set(cacheKey, response, 120); res.json(response); } catch (error) { - logger.withContext().error("Failed to get recent events", { error }); + logger.withContext().error('Failed to get recent events', { error }); res.status(500).json({ success: false, - message: "Failed to get recent events", + message: 'Failed to get recent events', }); } }; -export const listWebhookSubscriptions = async ( - _req: Request, - res: Response, -) => { +export const listWebhookSubscriptions = async (_req: Request, res: Response) => { try { const subscriptions = await webhookService.listSubscriptions(); @@ -509,20 +454,15 @@ export const listWebhookSubscriptions = async ( }, }); } catch (error) { - logger - .withContext() - .error("Failed to list webhook subscriptions", { error }); + logger.withContext().error('Failed to list webhook subscriptions', { error }); res.status(500).json({ success: false, - message: "Failed to list webhook subscriptions", + message: 'Failed to list webhook subscriptions', }); } }; -export const createWebhookSubscription = async ( - req: Request, - res: Response, -) => { +export const createWebhookSubscription = async (req: Request, res: Response) => { try { const { callbackUrl, eventTypes, secret } = req.body as { callbackUrl?: string; @@ -533,7 +473,7 @@ export const createWebhookSubscription = async ( if (!callbackUrl) { return res.status(400).json({ success: false, - message: "callbackUrl is required", + message: 'callbackUrl is required', }); } @@ -543,22 +483,21 @@ export const createWebhookSubscription = async ( } catch { return res.status(400).json({ success: false, - message: "callbackUrl must be a valid URL", + message: 'callbackUrl must be a valid URL', }); } - if (!["http:", "https:"].includes(parsedUrl.protocol)) { + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return res.status(400).json({ success: false, - message: "callbackUrl must use http or https", + message: 'callbackUrl must use http or https', }); } if (isPrivateHost(parsedUrl.hostname)) { return res.status(400).json({ success: false, - message: - "callbackUrl must not target a private, loopback, or link-local address", + message: 'callbackUrl must not target a private, loopback, or link-local address', }); } @@ -571,7 +510,7 @@ export const createWebhookSubscription = async ( if (normalizedEventTypes.length === 0) { return res.status(400).json({ success: false, - message: `eventTypes must include at least one of: ${SUPPORTED_WEBHOOK_EVENT_TYPES.join(", ")}`, + message: `eventTypes must include at least one of: ${SUPPORTED_WEBHOOK_EVENT_TYPES.join(', ')}`, }); } @@ -595,27 +534,22 @@ export const createWebhookSubscription = async ( }, }); } catch (error) { - logger - .withContext() - .error("Failed to create webhook subscription", { error }); + logger.withContext().error('Failed to create webhook subscription', { error }); res.status(500).json({ success: false, - message: "Failed to create webhook subscription", + message: 'Failed to create webhook subscription', }); } }; -export const deleteWebhookSubscription = async ( - req: Request, - res: Response, -) => { +export const deleteWebhookSubscription = async (req: Request, res: Response) => { try { const subscriptionId = Number(req.params.id ?? req.params.subscriptionId); if (!Number.isInteger(subscriptionId) || subscriptionId <= 0) { return res.status(400).json({ success: false, - message: "subscriptionId must be a positive integer", + message: 'subscriptionId must be a positive integer', }); } @@ -623,21 +557,19 @@ export const deleteWebhookSubscription = async ( if (!deleted) { return res.status(404).json({ success: false, - message: "Webhook subscription not found", + message: 'Webhook subscription not found', }); } res.json({ success: true, - message: "Webhook subscription deleted", + message: 'Webhook subscription deleted', }); } catch (error) { - logger - .withContext() - .error("Failed to delete webhook subscription", { error }); + logger.withContext().error('Failed to delete webhook subscription', { error }); res.status(500).json({ success: false, - message: "Failed to delete webhook subscription", + message: 'Failed to delete webhook subscription', }); } }; @@ -650,14 +582,11 @@ export const getWebhookDeliveries = async (req: Request, res: Response) => { if (!Number.isInteger(subscriptionId) || subscriptionId <= 0) { return res.status(400).json({ success: false, - message: "subscription id must be a positive integer", + message: 'subscription id must be a positive integer', }); } - const deliveries = await webhookService.getSubscriptionDeliveries( - subscriptionId, - limit, - ); + const deliveries = await webhookService.getSubscriptionDeliveries(subscriptionId, limit); res.json({ success: true, @@ -667,10 +596,10 @@ export const getWebhookDeliveries = async (req: Request, res: Response) => { }, }); } catch (error) { - logger.withContext().error("Failed to fetch webhook deliveries", { error }); + logger.withContext().error('Failed to fetch webhook deliveries', { error }); res.status(500).json({ success: false, - message: "Failed to fetch webhook deliveries", + message: 'Failed to fetch webhook deliveries', }); } }; @@ -683,14 +612,14 @@ export const reindexLedgerRange = async (req: Request, res: Response) => { if (!Number.isInteger(fromLedger) || !Number.isInteger(toLedger)) { return res.status(400).json({ success: false, - message: "fromLedger and toLedger must be integers", + message: 'fromLedger and toLedger must be integers', }); } if (fromLedger <= 0 || toLedger <= 0 || fromLedger > toLedger) { return res.status(400).json({ success: false, - message: "Ledger range is invalid", + message: 'Ledger range is invalid', }); } @@ -707,12 +636,10 @@ export const reindexLedgerRange = async (req: Request, res: Response) => { try { indexer = buildIndexerFromConfig(); } catch (error) { - logger - .withContext() - .error("Failed to initialize indexer for reindex", { error }); + logger.withContext().error('Failed to initialize indexer for reindex', { error }); return res.status(500).json({ success: false, - message: "Indexer is not configured", + message: 'Indexer is not configured', }); } @@ -723,10 +650,10 @@ export const reindexLedgerRange = async (req: Request, res: Response) => { data: result, }); } catch (error) { - logger.withContext().error("Failed to reindex ledger range", { error }); + logger.withContext().error('Failed to reindex ledger range', { error }); res.status(500).json({ success: false, - message: "Failed to reindex ledger range", + message: 'Failed to reindex ledger range', }); } }; @@ -739,7 +666,7 @@ export const listQuarantinedEvents = async (req: Request, res: Response) => { if (cursor && (!Number.isInteger(cursorValue) || (cursorValue ?? 0) <= 0)) { return res.status(400).json({ success: false, - message: "cursor must be a positive integer", + message: 'cursor must be a positive integer', }); } @@ -752,7 +679,7 @@ export const listQuarantinedEvents = async (req: Request, res: Response) => { LIMIT $2`, [cursorValue, limit + 1], ), - query("SELECT COUNT(*)::int AS count FROM quarantine_events", []), + query('SELECT COUNT(*)::int AS count FROM quarantine_events', []), ]); const hasNext = result.rows.length > limit; @@ -773,18 +700,15 @@ export const listQuarantinedEvents = async (req: Request, res: Response) => { res.json(response); } catch (error) { - logger.withContext().error("Failed to list quarantined events", { error }); + logger.withContext().error('Failed to list quarantined events', { error }); res.status(500).json({ success: false, - message: "Failed to list quarantined events", + message: 'Failed to list quarantined events', }); } }; -export const reprocessQuarantinedEvents = async ( - req: Request, - res: Response, -) => { +export const reprocessQuarantinedEvents = async (req: Request, res: Response) => { try { const { ids, limit } = req.body as { ids?: unknown; @@ -798,14 +722,12 @@ export const reprocessQuarantinedEvents = async ( if (Array.isArray(ids) && (!parsedIds || parsedIds.length !== ids.length)) { return res.status(400).json({ success: false, - message: "ids must be an array of positive integers", + message: 'ids must be an array of positive integers', }); } const parsedLimit = - typeof limit === "number" && Number.isInteger(limit) && limit > 0 - ? Math.min(limit, 500) - : 50; + typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50; const rowsResult = parsedIds && parsedIds.length > 0 @@ -830,14 +752,12 @@ export const reprocessQuarantinedEvents = async ( try { indexer = buildIndexerFromConfig(); } catch (error) { - logger - .withContext() - .error("Failed to initialize indexer for quarantine reprocess", { - error, - }); + logger.withContext().error('Failed to initialize indexer for quarantine reprocess', { + error, + }); return res.status(500).json({ success: false, - message: "Indexer is not configured", + message: 'Indexer is not configured', }); } @@ -853,11 +773,11 @@ export const reprocessQuarantinedEvents = async ( } await indexer.ingestRawEvents([rawEvent]); - await query("DELETE FROM quarantine_events WHERE id = $1", [row.id]); + await query('DELETE FROM quarantine_events WHERE id = $1', [row.id]); deleted += 1; } catch (error) { failed += 1; - logger.withContext().warn("Failed to reprocess quarantined event", { + logger.withContext().warn('Failed to reprocess quarantined event', { quarantineId: row.id, eventId: row.event_id, error, @@ -865,10 +785,7 @@ export const reprocessQuarantinedEvents = async ( } } - const remainingResult = await query( - "SELECT COUNT(*)::int AS count FROM quarantine_events", - [], - ); + const remainingResult = await query('SELECT COUNT(*)::int AS count FROM quarantine_events', []); res.json({ success: true, @@ -880,12 +797,10 @@ export const reprocessQuarantinedEvents = async ( }, }); } catch (error) { - logger - .withContext() - .error("Failed to reprocess quarantined events", { error }); + logger.withContext().error('Failed to reprocess quarantined events', { error }); res.status(500).json({ success: false, - message: "Failed to reprocess quarantined events", + message: 'Failed to reprocess quarantined events', }); } }; diff --git a/backend/src/controllers/loanController.ts b/backend/src/controllers/loanController.ts index 3a61e763..4d1d5cb2 100644 --- a/backend/src/controllers/loanController.ts +++ b/backend/src/controllers/loanController.ts @@ -1,23 +1,17 @@ -import type { Request, Response, NextFunction } from "express"; -import { query } from "../db/connection.js"; -import { withStellarAndDbTransaction } from "../db/transaction.js"; -import { AppError } from "../errors/AppError.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { getLoanConfig } from "../config/loanConfig.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { sorobanService } from "../services/sorobanService.js"; -import { rejectLoanSchema } from "../schemas/loanSchemas.js"; -import { - createCursorPaginatedResponse, - parseCursorQueryParams, -} from "../utils/pagination.js"; -import logger from "../utils/logger.js"; -import { cacheService } from "../services/cacheService.js"; -import { notificationService } from "../services/notificationService.js"; -import { - invalidateOnRepay, - invalidateOnLoanRequest, -} from "../utils/cacheKeys.js"; +import type { Request, Response, NextFunction } from 'express'; +import { query } from '../db/connection.js'; +import { withStellarAndDbTransaction } from '../db/transaction.js'; +import { AppError } from '../errors/AppError.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { getLoanConfig } from '../config/loanConfig.js'; +import { ErrorCode } from '../errors/errorCodes.js'; +import { sorobanService } from '../services/sorobanService.js'; +import { rejectLoanSchema } from '../schemas/loanSchemas.js'; +import { createCursorPaginatedResponse, parseCursorQueryParams } from '../utils/pagination.js'; +import logger from '../utils/logger.js'; +import { cacheService } from '../services/cacheService.js'; +import { notificationService } from '../services/notificationService.js'; +import { invalidateOnRepay, invalidateOnLoanRequest } from '../utils/cacheKeys.js'; // ─── Test/Dev Only ──────────────────────────────────────────────────────────── @@ -28,12 +22,10 @@ import { export const createTestLoan = asyncHandler( async (req: Request, res: Response, _next: NextFunction) => { const { amount, term } = req.body; - const borrower = req.user?.publicKey || "test-borrower"; + const borrower = req.user?.publicKey || 'test-borrower'; if (!amount || !term) { - res - .status(400) - .json({ success: false, message: "amount and term required" }); + res.status(400).json({ success: false, message: 'amount and term required' }); return; } @@ -56,35 +48,28 @@ export const createTestLoan = asyncHandler( }, ); -export const buildCancelLoanTx = async ( - req: Request, - res: Response, - next: NextFunction, -) => { +export const buildCancelLoanTx = async (req: Request, res: Response, next: NextFunction) => { try { const { loanId } = req.params; const borrower = (req as any).user?.publicKey as string; - const result = await query("SELECT * FROM loans WHERE id = $1", [loanId]); + const result = await query('SELECT * FROM loans WHERE id = $1', [loanId]); const loan = result.rows[0] as Record | undefined; if (!loan) { return res.status(404).json({ - message: "Loan not found", + message: 'Loan not found', }); } - if (!["PENDING", "OPEN"].includes(loan.status as string)) { + if (!['PENDING', 'OPEN'].includes(loan.status as string)) { return res.status(400).json({ - message: "Loan cannot be cancelled", + message: 'Loan cannot be cancelled', }); } - const transaction = await sorobanService.buildCancelLoanTx( - borrower, - loanId as string, - ); + const transaction = await sorobanService.buildCancelLoanTx(borrower, loanId as string); return res.json({ success: true, @@ -95,28 +80,24 @@ export const buildCancelLoanTx = async ( } }; -export const buildRejectLoanTx = async ( - req: Request, - res: Response, - next: NextFunction, -) => { +export const buildRejectLoanTx = async (req: Request, res: Response, next: NextFunction) => { try { const { loanId } = req.params; const { reason } = rejectLoanSchema.parse(req.body); - const result = await query("SELECT * FROM loans WHERE id = $1", [loanId]); + const result = await query('SELECT * FROM loans WHERE id = $1', [loanId]); const loan = result.rows[0] as Record | undefined; if (!loan) { return res.status(404).json({ - message: "Loan not found", + message: 'Loan not found', }); } - if (loan.status !== "PENDING") { + if (loan.status !== 'PENDING') { return res.status(400).json({ - message: "Loan cannot be rejected", + message: 'Loan cannot be rejected', }); } @@ -138,30 +119,27 @@ export const buildRejectLoanTx = async ( * POST /api/loans/:loanId/mark-defaulted (TEST/DEV ONLY) * Helper endpoint to mark a loan as defaulted for test setup. */ -export const markLoanDefaulted = asyncHandler( - async (req: Request, res: Response) => { - const loanId = req.params.loanId as string; - const borrower = req.body.borrower || req.user?.publicKey || null; +export const markLoanDefaulted = asyncHandler(async (req: Request, res: Response) => { + const loanId = req.params.loanId as string; + const borrower = req.body.borrower || req.user?.publicKey || null; - const loanResult = await query( - `SELECT loan_id FROM contract_events WHERE loan_id = $1 LIMIT 1`, - [loanId], - ); - if (loanResult.rows.length === 0) { - throw AppError.badRequest("Loan does not exist"); - } + const loanResult = await query(`SELECT loan_id FROM contract_events WHERE loan_id = $1 LIMIT 1`, [ + loanId, + ]); + if (loanResult.rows.length === 0) { + throw AppError.badRequest('Loan does not exist'); + } - await query( - `INSERT INTO contract_events (loan_id, address, event_type, amount, ledger, ledger_closed_at) VALUES ($1, $2, 'LoanDefaulted', NULL, NULL, NOW())`, - [loanId, borrower], - ); + await query( + `INSERT INTO contract_events (loan_id, address, event_type, amount, ledger, ledger_closed_at) VALUES ($1, $2, 'LoanDefaulted', NULL, NULL, NOW())`, + [loanId, borrower], + ); - res.json({ - success: true, - message: "Loan marked as defaulted for test setup.", - }); - }, -); + res.json({ + success: true, + message: 'Loan marked as defaulted for test setup.', + }); +}); /** * POST /api/loans/:loanId/contest-default @@ -174,10 +152,10 @@ export const contestDefault = asyncHandler( const borrower = req.user?.publicKey; if (!reason || reason.trim().length < 5) { - throw AppError.badRequest("A valid reason for contesting is required."); + throw AppError.badRequest('A valid reason for contesting is required.'); } if (!borrower) { - throw AppError.unauthorized("Authentication required"); + throw AppError.unauthorized('Authentication required'); } // Check loan exists and is defaulted @@ -186,7 +164,7 @@ export const contestDefault = asyncHandler( [loanId], ); if (loanResult.rows.length === 0) { - throw AppError.badRequest("Loan is not defaulted or does not exist"); + throw AppError.badRequest('Loan is not defaulted or does not exist'); } // Insert dispute record and return disputeId @@ -202,13 +180,11 @@ export const contestDefault = asyncHandler( [loanId, borrower], ); - logger - .withContext() - .info("Loan default contested", { loanId, borrower, reason }); + logger.withContext().info('Loan default contested', { loanId, borrower, reason }); // Notify admins via email, SSE, and optional webhook await notificationService.notifyAdmins({ - title: "Loan Default Contested", + title: 'Loan Default Contested', message: `Borrower ${borrower} has contested the default on loan #${loanId}. Reason: ${reason}`, loanId: Number(loanId), }); @@ -216,7 +192,7 @@ export const contestDefault = asyncHandler( res.json({ success: true, disputeId: disputeResult.rows[0].id, - message: "Loan default contested. Admins will review your dispute.", + message: 'Loan default contested. Admins will review your dispute.', }); }, ); @@ -232,7 +208,7 @@ type BorrowerLoan = { totalRepaid: number; totalOwed: number | null; nextPaymentDeadline: string; - status: "active" | "repaid" | "defaulted" | "pending_indexing"; + status: 'active' | 'repaid' | 'defaulted' | 'pending_indexing'; borrower: string; approvedAt: string | null; latestEventType?: string; @@ -240,15 +216,14 @@ type BorrowerLoan = { const getLatestLedger = async (): Promise => { const result = await query( - "SELECT last_indexed_ledger FROM indexer_state ORDER BY id DESC LIMIT 1", + 'SELECT last_indexed_ledger FROM indexer_state ORDER BY id DESC LIMIT 1', [], ); return result.rows[0]?.last_indexed_ledger ?? 0; }; -const roundToCents = (value: number): number => - Math.round((value + Number.EPSILON) * 100) / 100; +const roundToCents = (value: number): number => Math.round((value + Number.EPSILON) * 100) / 100; const addDays = (date: Date, days: number): Date => { const result = new Date(date); @@ -320,58 +295,47 @@ const buildAmortizationSchedule = ( }; }; -export const previewLoanAmortizationSchedule = asyncHandler( - async (req: Request, res: Response) => { - const { amount, termDays } = req.body as { - amount: number; - termDays: 30 | 60 | 90; - }; +export const previewLoanAmortizationSchedule = asyncHandler(async (req: Request, res: Response) => { + const { amount, termDays } = req.body as { + amount: number; + termDays: 30 | 60 | 90; + }; - const loanConfig = getLoanConfig(); - const interestRateBps = Math.round(loanConfig.interestRatePercent * 100); - const termLedgers = termDays * DEFAULT_TERM_LEDGERS; + const loanConfig = getLoanConfig(); + const interestRateBps = Math.round(loanConfig.interestRatePercent * 100); + const termLedgers = termDays * DEFAULT_TERM_LEDGERS; - const amortization = buildAmortizationSchedule( - amount, - interestRateBps, - termLedgers, - new Date(), - ); + const amortization = buildAmortizationSchedule(amount, interestRateBps, termLedgers, new Date()); - res.json({ - success: true, - amortization, - }); - }, -); + res.json({ + success: true, + amortization, + }); +}); /** * Get active loans for a borrower * * GET /api/loans/borrower/:borrower */ -export const getBorrowerLoans = asyncHandler( - async (req: Request, res: Response) => { - const { borrower } = req.params; - const { limit, cursor, status, dateRange, amountRange } = - parseCursorQueryParams(req); - - // `from` and `to` are validated by the Zod middleware; merge into dateRange - const fromParam = - typeof req.query.from === "string" ? new Date(req.query.from) : null; - const toParam = - typeof req.query.to === "string" ? new Date(req.query.to) : null; - const effectiveDateRange = - fromParam !== null || toParam !== null - ? { - start: fromParam ?? new Date(0), - end: toParam ?? new Date(), - } - : dateRange; - - const currentLedger = await getLatestLedger(); - - const loansQuery = ` +export const getBorrowerLoans = asyncHandler(async (req: Request, res: Response) => { + const { borrower } = req.params; + const { limit, cursor, status, dateRange, amountRange } = parseCursorQueryParams(req); + + // `from` and `to` are validated by the Zod middleware; merge into dateRange + const fromParam = typeof req.query.from === 'string' ? new Date(req.query.from) : null; + const toParam = typeof req.query.to === 'string' ? new Date(req.query.to) : null; + const effectiveDateRange = + fromParam !== null || toParam !== null + ? { + start: fromParam ?? new Date(0), + end: toParam ?? new Date(), + } + : dateRange; + + const currentLedger = await getLatestLedger(); + + const loansQuery = ` WITH loan_summaries AS ( SELECT loan_id, @@ -435,296 +399,254 @@ export const getBorrowerLoans = asyncHandler( LIMIT $9 `; - const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; - const queryParams = [ - borrower, - currentLedger, - status && status !== "all" ? status : null, - amountRange?.min ?? null, - amountRange?.max ?? null, - effectiveDateRange?.start ?? null, - effectiveDateRange?.end ?? null, - cursorValue, - limit + 1, - ]; - - const result = await query(loansQuery, queryParams); - - const totalCount = - result.rows.length > 0 - ? Number.parseInt(result.rows[0].full_count, 10) - : 0; - - const hasNext = result.rows.length > limit; - const trimmedRows = hasNext ? result.rows.slice(0, limit) : result.rows; - - const loans: BorrowerLoan[] = trimmedRows.map( - (row: Record) => { - const isPending = row.status === "pending_indexing"; - return { - loanId: Number(row.loan_id), - principal: Number.parseFloat((row.principal as string) || "0"), - accruedInterest: isPending - ? null - : Number.parseFloat((row.accrued_interest as string) || "0"), - totalRepaid: Number.parseFloat((row.total_repaid as string) || "0"), - totalOwed: isPending - ? null - : Number.parseFloat((row.total_owed as string) || "0"), - nextPaymentDeadline: new Date( - row.next_payment_deadline as string, - ).toISOString(), - status: row.status as - | "active" - | "repaid" - | "defaulted" - | "pending_indexing", - borrower: row.address as string, - approvedAt: row.approved_at - ? new Date(row.approved_at as string).toISOString() - : null, - ...(typeof row.latest_event_type === "string" - ? { latestEventType: row.latest_event_type as string } - : {}), - }; - }, - ); + const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; + const queryParams = [ + borrower, + currentLedger, + status && status !== 'all' ? status : null, + amountRange?.min ?? null, + amountRange?.max ?? null, + effectiveDateRange?.start ?? null, + effectiveDateRange?.end ?? null, + cursorValue, + limit + 1, + ]; + + const result = await query(loansQuery, queryParams); + + const totalCount = result.rows.length > 0 ? Number.parseInt(result.rows[0].full_count, 10) : 0; + + const hasNext = result.rows.length > limit; + const trimmedRows = hasNext ? result.rows.slice(0, limit) : result.rows; + + const loans: BorrowerLoan[] = trimmedRows.map((row: Record) => { + const isPending = row.status === 'pending_indexing'; + return { + loanId: Number(row.loan_id), + principal: Number.parseFloat((row.principal as string) || '0'), + accruedInterest: isPending + ? null + : Number.parseFloat((row.accrued_interest as string) || '0'), + totalRepaid: Number.parseFloat((row.total_repaid as string) || '0'), + totalOwed: isPending ? null : Number.parseFloat((row.total_owed as string) || '0'), + nextPaymentDeadline: new Date(row.next_payment_deadline as string).toISOString(), + status: row.status as 'active' | 'repaid' | 'defaulted' | 'pending_indexing', + borrower: row.address as string, + approvedAt: row.approved_at ? new Date(row.approved_at as string).toISOString() : null, + ...(typeof row.latest_event_type === 'string' + ? { latestEventType: row.latest_event_type as string } + : {}), + }; + }); - const lastLoan = loans.length > 0 ? loans[loans.length - 1] : undefined; - const nextCursor = hasNext && lastLoan ? String(lastLoan.loanId) : null; - - res.json( - createCursorPaginatedResponse( - { - borrower, - loans, - }, - totalCount, - limit, - loans.length, - nextCursor, - Boolean(cursor), - ), - ); - }, -); + const lastLoan = loans.length > 0 ? loans[loans.length - 1] : undefined; + const nextCursor = hasNext && lastLoan ? String(lastLoan.loanId) : null; + + res.json( + createCursorPaginatedResponse( + { + borrower, + loans, + }, + totalCount, + limit, + loans.length, + nextCursor, + Boolean(cursor), + ), + ); +}); /** * GET /api/loans/config */ -export const getLoanConfigEndpoint = asyncHandler( - async (_req: Request, res: Response) => { - const loanConfig = getLoanConfig(); +export const getLoanConfigEndpoint = asyncHandler(async (_req: Request, res: Response) => { + const loanConfig = getLoanConfig(); - res.json({ - success: true, - data: { - minScore: loanConfig.minScore, - maxAmount: loanConfig.maxAmount, - interestRatePercent: loanConfig.interestRatePercent, - creditScoreThreshold: loanConfig.creditScoreThreshold, - }, - }); - }, -); + res.json({ + success: true, + data: { + minScore: loanConfig.minScore, + maxAmount: loanConfig.maxAmount, + interestRatePercent: loanConfig.interestRatePercent, + creditScoreThreshold: loanConfig.creditScoreThreshold, + }, + }); +}); /** * Get detailed loan history and current stats * * GET /api/loans/:loanId */ -export const getLoanDetails = asyncHandler( - async (req: Request, res: Response) => { - const { loanId } = req.params; +export const getLoanDetails = asyncHandler(async (req: Request, res: Response) => { + const { loanId } = req.params; - const eventsResult = await query( - `SELECT id, event_type, amount, ledger, ledger_closed_at, tx_hash, interest_rate_bps, term_ledgers + const eventsResult = await query( + `SELECT id, event_type, amount, ledger, ledger_closed_at, tx_hash, interest_rate_bps, term_ledgers FROM contract_events WHERE loan_id = $1 ORDER BY ledger_closed_at ASC, ledger ASC, id ASC`, - [loanId], - ); + [loanId], + ); - if (eventsResult.rows.length === 0) { - throw AppError.notFound( - "Loan not found", - ErrorCode.LOAN_NOT_FOUND, - "loanId", - ); - } + if (eventsResult.rows.length === 0) { + throw AppError.notFound('Loan not found', ErrorCode.LOAN_NOT_FOUND, 'loanId'); + } - const events = eventsResult.rows; - const currentLedger = await getLatestLedger(); - const requestEvent = events.find( - (event: Record) => event.event_type === "LoanRequested", - ); - const approvalEvents = events.filter( - (event: Record) => event.event_type === "LoanApproved", - ); - if (approvalEvents.length > 1) { - logger - .withContext() - .warn("Duplicate LoanApproved events detected for loan", { - loanId, - duplicateCount: approvalEvents.length, - }); - } - const approvalEvent = - approvalEvents.length > 0 - ? approvalEvents[approvalEvents.length - 1] - : undefined; - const repaymentEvents = events.filter( - (event: Record) => event.event_type === "LoanRepaid", - ); + const events = eventsResult.rows; + const currentLedger = await getLatestLedger(); + const requestEvent = events.find( + (event: Record) => event.event_type === 'LoanRequested', + ); + const approvalEvents = events.filter( + (event: Record) => event.event_type === 'LoanApproved', + ); + if (approvalEvents.length > 1) { + logger.withContext().warn('Duplicate LoanApproved events detected for loan', { + loanId, + duplicateCount: approvalEvents.length, + }); + } + const approvalEvent = + approvalEvents.length > 0 ? approvalEvents[approvalEvents.length - 1] : undefined; + const repaymentEvents = events.filter( + (event: Record) => event.event_type === 'LoanRepaid', + ); - const principal = Number.parseFloat(requestEvent?.amount || "0"); - const totalRepaid = repaymentEvents.reduce( - (sum: number, event: Record) => - sum + Number.parseFloat((event.amount as string) || "0"), - 0, - ); + const principal = Number.parseFloat(requestEvent?.amount || '0'); + const totalRepaid = repaymentEvents.reduce( + (sum: number, event: Record) => + sum + Number.parseFloat((event.amount as string) || '0'), + 0, + ); - const rateBps = - approvalEvent?.interest_rate_bps || DEFAULT_INTEREST_RATE_BPS; - const termLedgers = approvalEvent?.term_ledgers || DEFAULT_TERM_LEDGERS; - const approvedLedger = approvalEvent?.ledger || 0; + const rateBps = approvalEvent?.interest_rate_bps || DEFAULT_INTEREST_RATE_BPS; + const termLedgers = approvalEvent?.term_ledgers || DEFAULT_TERM_LEDGERS; + const approvedLedger = approvalEvent?.ledger || 0; - // Check for open dispute - const disputeResult = await query( - `SELECT created_at FROM loan_disputes WHERE loan_id = $1 AND status = 'open' ORDER BY created_at ASC LIMIT 1`, - [loanId], + // Check for open dispute + const disputeResult = await query( + `SELECT created_at FROM loan_disputes WHERE loan_id = $1 AND status = 'open' ORDER BY created_at ASC LIMIT 1`, + [loanId], + ); + let freezeLedger: number | null = null; + if (disputeResult.rows.length > 0) { + // Find the ledger closest to dispute creation + const disputeCreatedAt = new Date(disputeResult.rows[0].created_at); + // Find the ledger that closed just before or at disputeCreatedAt + const ledgerResult = await query( + `SELECT ledger, ledger_closed_at FROM contract_events WHERE loan_id = $1 AND ledger_closed_at <= $2 ORDER BY ledger_closed_at DESC LIMIT 1`, + [loanId, disputeCreatedAt], ); - let freezeLedger: number | null = null; - if (disputeResult.rows.length > 0) { - // Find the ledger closest to dispute creation - const disputeCreatedAt = new Date(disputeResult.rows[0].created_at); - // Find the ledger that closed just before or at disputeCreatedAt - const ledgerResult = await query( - `SELECT ledger, ledger_closed_at FROM contract_events WHERE loan_id = $1 AND ledger_closed_at <= $2 ORDER BY ledger_closed_at DESC LIMIT 1`, - [loanId, disputeCreatedAt], - ); - freezeLedger = - ledgerResult.rows.length > 0 ? ledgerResult.rows[0].ledger : null; - } + freezeLedger = ledgerResult.rows.length > 0 ? ledgerResult.rows[0].ledger : null; + } - let elapsedLedgers: number; - if (freezeLedger !== null) { - elapsedLedgers = Math.max(0, freezeLedger - approvedLedger); - } else { - elapsedLedgers = Math.max(0, currentLedger - approvedLedger); - } + let elapsedLedgers: number; + if (freezeLedger !== null) { + elapsedLedgers = Math.max(0, freezeLedger - approvedLedger); + } else { + elapsedLedgers = Math.max(0, currentLedger - approvedLedger); + } - const isDefaulted = events.some( - (event: Record) => event.event_type === "LoanDefaulted", - ); + const isDefaulted = events.some( + (event: Record) => event.event_type === 'LoanDefaulted', + ); - const isPending = approvedLedger <= 0 || currentLedger < approvedLedger; + const isPending = approvedLedger <= 0 || currentLedger < approvedLedger; - const accruedInterest = isPending - ? 0 - : (principal * rateBps * elapsedLedgers) / (10000 * termLedgers); - const totalOwed = principal + accruedInterest - totalRepaid; + const accruedInterest = isPending + ? 0 + : (principal * rateBps * elapsedLedgers) / (10000 * termLedgers); + const totalOwed = principal + accruedInterest - totalRepaid; - res.json({ - success: true, - loanId, - summary: { - principal, - accruedInterest: isPending ? null : accruedInterest, - totalRepaid, - totalOwed: isPending ? null : totalOwed, - interestRate: rateBps / 10000, - termLedgers, - elapsedLedgers, - status: isPending - ? "pending_indexing" - : isDefaulted - ? "defaulted" - : totalOwed > 0.01 - ? "active" - : "repaid", - requestedAt: requestEvent?.ledger_closed_at, - approvedAt: approvalEvent?.ledger_closed_at, - events: events.map((event: Record) => ({ - type: event.event_type, - amount: event.amount, - timestamp: event.ledger_closed_at, - tx: event.tx_hash, - })), - disputeFrozen: freezeLedger !== null, - }, - }); - }, -); + res.json({ + success: true, + loanId, + summary: { + principal, + accruedInterest: isPending ? null : accruedInterest, + totalRepaid, + totalOwed: isPending ? null : totalOwed, + interestRate: rateBps / 10000, + termLedgers, + elapsedLedgers, + status: isPending + ? 'pending_indexing' + : isDefaulted + ? 'defaulted' + : totalOwed > 0.01 + ? 'active' + : 'repaid', + requestedAt: requestEvent?.ledger_closed_at, + approvedAt: approvalEvent?.ledger_closed_at, + events: events.map((event: Record) => ({ + type: event.event_type, + amount: event.amount, + timestamp: event.ledger_closed_at, + tx: event.tx_hash, + })), + disputeFrozen: freezeLedger !== null, + }, + }); +}); -export const getLoanAmortizationSchedule = asyncHandler( - async (req: Request, res: Response) => { - const { loanId } = req.params; +export const getLoanAmortizationSchedule = asyncHandler(async (req: Request, res: Response) => { + const { loanId } = req.params; - const eventsResult = await query( - `SELECT id, event_type, amount, ledger, ledger_closed_at, interest_rate_bps, term_ledgers + const eventsResult = await query( + `SELECT id, event_type, amount, ledger, ledger_closed_at, interest_rate_bps, term_ledgers FROM contract_events WHERE loan_id = $1 ORDER BY ledger_closed_at ASC, ledger ASC, id ASC`, - [loanId], - ); + [loanId], + ); - if (eventsResult.rows.length === 0) { - throw AppError.notFound( - "Loan not found", - ErrorCode.LOAN_NOT_FOUND, - "loanId", - ); - } + if (eventsResult.rows.length === 0) { + throw AppError.notFound('Loan not found', ErrorCode.LOAN_NOT_FOUND, 'loanId'); + } - const events = eventsResult.rows; - const requestEvent = events.find( - (event: Record) => event.event_type === "LoanRequested", - ); - const approvalEvents = events.filter( - (event: Record) => event.event_type === "LoanApproved", - ); - const approvalEvent = - approvalEvents.length > 0 - ? approvalEvents[approvalEvents.length - 1] - : undefined; - - if (!requestEvent || !approvalEvent || !requestEvent.amount) { - throw AppError.notFound( - "Loan not fully approved", - ErrorCode.LOAN_NOT_FOUND, - "loanId", - ); - } + const events = eventsResult.rows; + const requestEvent = events.find( + (event: Record) => event.event_type === 'LoanRequested', + ); + const approvalEvents = events.filter( + (event: Record) => event.event_type === 'LoanApproved', + ); + const approvalEvent = + approvalEvents.length > 0 ? approvalEvents[approvalEvents.length - 1] : undefined; - const principal = Number.parseFloat(String(requestEvent.amount)); - const interestRateBps = Number.parseInt( - String(approvalEvent.interest_rate_bps ?? DEFAULT_INTEREST_RATE_BPS), - 10, - ); - const termLedgers = Number.parseInt( - String(approvalEvent.term_ledgers ?? DEFAULT_TERM_LEDGERS), - 10, - ); + if (!requestEvent || !approvalEvent || !requestEvent.amount) { + throw AppError.notFound('Loan not fully approved', ErrorCode.LOAN_NOT_FOUND, 'loanId'); + } - const approvedAt = approvalEvent.ledger_closed_at - ? new Date(approvalEvent.ledger_closed_at) - : new Date(); + const principal = Number.parseFloat(String(requestEvent.amount)); + const interestRateBps = Number.parseInt( + String(approvalEvent.interest_rate_bps ?? DEFAULT_INTEREST_RATE_BPS), + 10, + ); + const termLedgers = Number.parseInt( + String(approvalEvent.term_ledgers ?? DEFAULT_TERM_LEDGERS), + 10, + ); - const amortization = buildAmortizationSchedule( - principal, - interestRateBps, - termLedgers, - approvedAt, - ); + const approvedAt = approvalEvent.ledger_closed_at + ? new Date(approvalEvent.ledger_closed_at) + : new Date(); - res.json({ - success: true, - loanId, - amortization, - }); - }, -); + const amortization = buildAmortizationSchedule( + principal, + interestRateBps, + termLedgers, + approvedAt, + ); + + res.json({ + success: true, + loanId, + amortization, + }); +}); /** * POST /api/loans/request @@ -737,24 +659,23 @@ export const requestLoan = asyncHandler(async (req: Request, res: Response) => { if (borrowerPublicKey !== req.user?.publicKey) { throw AppError.forbidden( - "borrowerPublicKey must match your authenticated wallet", + 'borrowerPublicKey must match your authenticated wallet', ErrorCode.BORROWER_MISMATCH, ); } if ( - process.env.NODE_ENV !== "test" && - "getPoolBalance" in sorobanService && - typeof ( - sorobanService as unknown as { getPoolBalance?: () => Promise } - ).getPoolBalance === "function" + process.env.NODE_ENV !== 'test' && + 'getPoolBalance' in sorobanService && + typeof (sorobanService as unknown as { getPoolBalance?: () => Promise }) + .getPoolBalance === 'function' ) { const poolBalance = await ( sorobanService as unknown as { getPoolBalance: () => Promise } ).getPoolBalance(); if (amount > poolBalance) { throw AppError.badRequest( - "Insufficient pool liquidity to cover this loan", + 'Insufficient pool liquidity to cover this loan', ErrorCode.INSUFFICIENT_BALANCE, ); } @@ -768,7 +689,7 @@ export const requestLoan = asyncHandler(async (req: Request, res: Response) => { }>(cacheKey); if (cachedTx) { - logger.withContext().info("Returning cached unsigned loan request tx", { + logger.withContext().info('Returning cached unsigned loan request tx', { borrower: borrowerPublicKey, amount, }); @@ -780,10 +701,7 @@ export const requestLoan = asyncHandler(async (req: Request, res: Response) => { return; } - const result = await sorobanService.buildRequestLoanTx( - borrowerPublicKey, - amount, - ); + const result = await sorobanService.buildRequestLoanTx(borrowerPublicKey, amount); // Cache for 60 seconds to prevent sequence number collisions from rapid requests await cacheService.set(cacheKey, result, 60); @@ -791,7 +709,7 @@ export const requestLoan = asyncHandler(async (req: Request, res: Response) => { // Invalidate stale read-cache keys now that a loan request has been built await invalidateOnLoanRequest(borrowerPublicKey); - logger.withContext().info("Loan request transaction built", { + logger.withContext().info('Loan request transaction built', { borrower: borrowerPublicKey, amount, }); @@ -816,18 +734,14 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => { if (borrowerPublicKey !== req.user?.publicKey) { throw AppError.forbidden( - "borrowerPublicKey must match your authenticated wallet", + 'borrowerPublicKey must match your authenticated wallet', ErrorCode.BORROWER_MISMATCH, ); } const loanIdNum = Number.parseInt(loanId, 10); if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { - throw AppError.badRequest( - "Invalid loan ID", - ErrorCode.INVALID_LOAN_ID, - "loanId", - ); + throw AppError.badRequest('Invalid loan ID', ErrorCode.INVALID_LOAN_ID, 'loanId'); } // Idempotency: return existing unsigned tx if recently built for this borrower/loan/amount @@ -838,7 +752,7 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => { }>(cacheKey); if (cachedTx) { - logger.withContext().info("Returning cached unsigned repay tx", { + logger.withContext().info('Returning cached unsigned repay tx', { borrower: borrowerPublicKey, loanId: loanIdNum, amount, @@ -852,11 +766,7 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => { return; } - const result = await sorobanService.buildRepayTx( - borrowerPublicKey, - loanIdNum, - amount, - ); + const result = await sorobanService.buildRepayTx(borrowerPublicKey, loanIdNum, amount); // Cache for 60 seconds await cacheService.set(cacheKey, result, 60); @@ -864,7 +774,7 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => { // Invalidate stale read-cache keys now that a repayment has been initiated await invalidateOnRepay(borrowerPublicKey, loanIdNum); - logger.withContext().info("Repay transaction built", { + logger.withContext().info('Repay transaction built', { borrower: borrowerPublicKey, loanId: loanIdNum, amount, @@ -882,219 +792,194 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => { /** * POST /api/loans/:loanId/build-deposit-collateral */ -export const depositCollateral = asyncHandler( - async (req: Request, res: Response) => { - const loanId = req.params.loanId as string; - const { amount, borrowerPublicKey } = req.body as { - amount: number; - borrowerPublicKey: string; - }; - - if (borrowerPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "borrowerPublicKey must match your authenticated wallet", - ErrorCode.BORROWER_MISMATCH, - ); - } - - const loanIdNum = Number.parseInt(loanId, 10); - if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { - throw AppError.badRequest( - "Invalid loan ID", - ErrorCode.INVALID_LOAN_ID, - "loanId", - ); - } - - const cacheKey = `pending_deposit_collateral_tx:${borrowerPublicKey}:${loanIdNum}:${amount}`; - const cachedTx = await cacheService.get<{ - unsignedTxXdr: string; - networkPassphrase: string; - }>(cacheKey); - - if (cachedTx) { - logger - .withContext() - .info("Returning cached unsigned deposit_collateral tx", { - borrower: borrowerPublicKey, - loanId: loanIdNum, - amount, - }); - res.json({ - success: true, - loanId: loanIdNum, - unsignedTxXdr: cachedTx.unsignedTxXdr, - networkPassphrase: cachedTx.networkPassphrase, - }); - return; - } +export const depositCollateral = asyncHandler(async (req: Request, res: Response) => { + const loanId = req.params.loanId as string; + const { amount, borrowerPublicKey } = req.body as { + amount: number; + borrowerPublicKey: string; + }; - const result = await sorobanService.buildDepositCollateralTx( - borrowerPublicKey, - loanIdNum, - amount, + if (borrowerPublicKey !== req.user?.publicKey) { + throw AppError.forbidden( + 'borrowerPublicKey must match your authenticated wallet', + ErrorCode.BORROWER_MISMATCH, ); + } - await cacheService.set(cacheKey, result, 60); + const loanIdNum = Number.parseInt(loanId, 10); + if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { + throw AppError.badRequest('Invalid loan ID', ErrorCode.INVALID_LOAN_ID, 'loanId'); + } + + const cacheKey = `pending_deposit_collateral_tx:${borrowerPublicKey}:${loanIdNum}:${amount}`; + const cachedTx = await cacheService.get<{ + unsignedTxXdr: string; + networkPassphrase: string; + }>(cacheKey); - logger.withContext().info("Deposit collateral transaction built", { + if (cachedTx) { + logger.withContext().info('Returning cached unsigned deposit_collateral tx', { borrower: borrowerPublicKey, loanId: loanIdNum, amount, }); - res.json({ success: true, loanId: loanIdNum, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, + unsignedTxXdr: cachedTx.unsignedTxXdr, + networkPassphrase: cachedTx.networkPassphrase, }); - }, -); + return; + } -/** - * POST /api/loans/:loanId/build-release-collateral - */ -export const releaseCollateral = asyncHandler( - async (req: Request, res: Response) => { - const loanId = req.params.loanId as string; - const { borrowerPublicKey } = req.body as { - borrowerPublicKey: string; - }; + const result = await sorobanService.buildDepositCollateralTx( + borrowerPublicKey, + loanIdNum, + amount, + ); - if (borrowerPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "borrowerPublicKey must match your authenticated wallet", - ErrorCode.BORROWER_MISMATCH, - ); - } + await cacheService.set(cacheKey, result, 60); - const loanIdNum = Number.parseInt(loanId, 10); - if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { - throw AppError.badRequest( - "Invalid loan ID", - ErrorCode.INVALID_LOAN_ID, - "loanId", - ); - } + logger.withContext().info('Deposit collateral transaction built', { + borrower: borrowerPublicKey, + loanId: loanIdNum, + amount, + }); - const cacheKey = `pending_release_collateral_tx:${borrowerPublicKey}:${loanIdNum}`; - const cachedTx = await cacheService.get<{ - unsignedTxXdr: string; - networkPassphrase: string; - }>(cacheKey); - - if (cachedTx) { - logger - .withContext() - .info("Returning cached unsigned release_collateral tx", { - borrower: borrowerPublicKey, - loanId: loanIdNum, - }); - res.json({ - success: true, - loanId: loanIdNum, - unsignedTxXdr: cachedTx.unsignedTxXdr, - networkPassphrase: cachedTx.networkPassphrase, - }); - return; - } + res.json({ + success: true, + loanId: loanIdNum, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); - const result = await sorobanService.buildReleaseCollateralTx( - borrowerPublicKey, - loanIdNum, +/** + * POST /api/loans/:loanId/build-release-collateral + */ +export const releaseCollateral = asyncHandler(async (req: Request, res: Response) => { + const loanId = req.params.loanId as string; + const { borrowerPublicKey } = req.body as { + borrowerPublicKey: string; + }; + + if (borrowerPublicKey !== req.user?.publicKey) { + throw AppError.forbidden( + 'borrowerPublicKey must match your authenticated wallet', + ErrorCode.BORROWER_MISMATCH, ); + } + + const loanIdNum = Number.parseInt(loanId, 10); + if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { + throw AppError.badRequest('Invalid loan ID', ErrorCode.INVALID_LOAN_ID, 'loanId'); + } - await cacheService.set(cacheKey, result, 60); + const cacheKey = `pending_release_collateral_tx:${borrowerPublicKey}:${loanIdNum}`; + const cachedTx = await cacheService.get<{ + unsignedTxXdr: string; + networkPassphrase: string; + }>(cacheKey); - logger.withContext().info("Release collateral transaction built", { + if (cachedTx) { + logger.withContext().info('Returning cached unsigned release_collateral tx', { borrower: borrowerPublicKey, loanId: loanIdNum, }); - res.json({ success: true, loanId: loanIdNum, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, + unsignedTxXdr: cachedTx.unsignedTxXdr, + networkPassphrase: cachedTx.networkPassphrase, }); - }, -); + return; + } -/** - * POST /api/loans/:loanId/build-refinance - */ -export const refinanceLoan = asyncHandler( - async (req: Request, res: Response) => { - const loanId = req.params.loanId as string; - const { newAmount, newTerm, borrowerPublicKey } = req.body as { - newAmount: number; - newTerm: number; - borrowerPublicKey: string; - }; + const result = await sorobanService.buildReleaseCollateralTx(borrowerPublicKey, loanIdNum); - if (borrowerPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "borrowerPublicKey must match your authenticated wallet", - ErrorCode.BORROWER_MISMATCH, - ); - } + await cacheService.set(cacheKey, result, 60); - const loanIdNum = Number.parseInt(loanId, 10); - if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { - throw AppError.badRequest( - "Invalid loan ID", - ErrorCode.INVALID_LOAN_ID, - "loanId", - ); - } + logger.withContext().info('Release collateral transaction built', { + borrower: borrowerPublicKey, + loanId: loanIdNum, + }); - const cacheKey = `pending_refinance_tx:${borrowerPublicKey}:${loanIdNum}:${newAmount}:${newTerm}`; - const cachedTx = await cacheService.get<{ - unsignedTxXdr: string; - networkPassphrase: string; - }>(cacheKey); - - if (cachedTx) { - logger.withContext().info("Returning cached unsigned refinance tx", { - borrower: borrowerPublicKey, - loanId: loanIdNum, - newAmount, - newTerm, - }); - res.json({ - success: true, - loanId: loanIdNum, - unsignedTxXdr: cachedTx.unsignedTxXdr, - networkPassphrase: cachedTx.networkPassphrase, - }); - return; - } + res.json({ + success: true, + loanId: loanIdNum, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); - const result = await sorobanService.buildRefinanceLoanTx( - borrowerPublicKey, - loanIdNum, - newAmount, - newTerm, +/** + * POST /api/loans/:loanId/build-refinance + */ +export const refinanceLoan = asyncHandler(async (req: Request, res: Response) => { + const loanId = req.params.loanId as string; + const { newAmount, newTerm, borrowerPublicKey } = req.body as { + newAmount: number; + newTerm: number; + borrowerPublicKey: string; + }; + + if (borrowerPublicKey !== req.user?.publicKey) { + throw AppError.forbidden( + 'borrowerPublicKey must match your authenticated wallet', + ErrorCode.BORROWER_MISMATCH, ); + } + + const loanIdNum = Number.parseInt(loanId, 10); + if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { + throw AppError.badRequest('Invalid loan ID', ErrorCode.INVALID_LOAN_ID, 'loanId'); + } - await cacheService.set(cacheKey, result, 60); + const cacheKey = `pending_refinance_tx:${borrowerPublicKey}:${loanIdNum}:${newAmount}:${newTerm}`; + const cachedTx = await cacheService.get<{ + unsignedTxXdr: string; + networkPassphrase: string; + }>(cacheKey); - logger.withContext().info("Refinance loan transaction built", { + if (cachedTx) { + logger.withContext().info('Returning cached unsigned refinance tx', { borrower: borrowerPublicKey, loanId: loanIdNum, newAmount, newTerm, }); - res.json({ success: true, loanId: loanIdNum, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, + unsignedTxXdr: cachedTx.unsignedTxXdr, + networkPassphrase: cachedTx.networkPassphrase, }); - }, -); + return; + } + + const result = await sorobanService.buildRefinanceLoanTx( + borrowerPublicKey, + loanIdNum, + newAmount, + newTerm, + ); + + await cacheService.set(cacheKey, result, 60); + + logger.withContext().info('Refinance loan transaction built', { + borrower: borrowerPublicKey, + loanId: loanIdNum, + newAmount, + newTerm, + }); + + res.json({ + success: true, + loanId: loanIdNum, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); /** * POST /api/loans/:loanId/build-extend @@ -1108,18 +993,14 @@ export const extendLoan = asyncHandler(async (req: Request, res: Response) => { if (borrowerPublicKey !== req.user?.publicKey) { throw AppError.forbidden( - "borrowerPublicKey must match your authenticated wallet", + 'borrowerPublicKey must match your authenticated wallet', ErrorCode.BORROWER_MISMATCH, ); } const loanIdNum = Number.parseInt(loanId, 10); if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { - throw AppError.badRequest( - "Invalid loan ID", - ErrorCode.INVALID_LOAN_ID, - "loanId", - ); + throw AppError.badRequest('Invalid loan ID', ErrorCode.INVALID_LOAN_ID, 'loanId'); } const cacheKey = `pending_extend_tx:${borrowerPublicKey}:${loanIdNum}:${extraLedgers}`; @@ -1129,7 +1010,7 @@ export const extendLoan = asyncHandler(async (req: Request, res: Response) => { }>(cacheKey); if (cachedTx) { - logger.withContext().info("Returning cached unsigned extend tx", { + logger.withContext().info('Returning cached unsigned extend tx', { borrower: borrowerPublicKey, loanId: loanIdNum, extraLedgers, @@ -1143,15 +1024,11 @@ export const extendLoan = asyncHandler(async (req: Request, res: Response) => { return; } - const result = await sorobanService.buildExtendLoanTx( - borrowerPublicKey, - loanIdNum, - extraLedgers, - ); + const result = await sorobanService.buildExtendLoanTx(borrowerPublicKey, loanIdNum, extraLedgers); await cacheService.set(cacheKey, result, 60); - logger.withContext().info("Extend loan transaction built", { + logger.withContext().info('Extend loan transaction built', { borrower: borrowerPublicKey, loanId: loanIdNum, extraLedgers, @@ -1168,130 +1045,115 @@ export const extendLoan = asyncHandler(async (req: Request, res: Response) => { /** * POST /api/loans/:loanId/liquidate/build */ -export const buildLiquidateLoan = asyncHandler( - async (req: Request, res: Response) => { - const loanId = req.params.loanId as string; - const { liquidatorPublicKey } = req.body as { - liquidatorPublicKey: string; - }; - - if (liquidatorPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "liquidatorPublicKey must match your authenticated wallet", - ErrorCode.ACCESS_DENIED, - ); - } - - const loanIdNum = Number.parseInt(loanId, 10); - if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { - throw AppError.badRequest( - "Invalid loan ID", - ErrorCode.INVALID_LOAN_ID, - "loanId", - ); - } - - const cacheKey = `pending_liquidate_tx:${liquidatorPublicKey}:${loanIdNum}`; - const cachedTx = await cacheService.get<{ - unsignedTxXdr: string; - networkPassphrase: string; - }>(cacheKey); - - if (cachedTx) { - logger.withContext().info("Returning cached unsigned liquidate tx", { - liquidator: liquidatorPublicKey, - loanId: loanIdNum, - }); - res.json({ - success: true, - loanId: loanIdNum, - unsignedTxXdr: cachedTx.unsignedTxXdr, - networkPassphrase: cachedTx.networkPassphrase, - }); - return; - } +export const buildLiquidateLoan = asyncHandler(async (req: Request, res: Response) => { + const loanId = req.params.loanId as string; + const { liquidatorPublicKey } = req.body as { + liquidatorPublicKey: string; + }; - const result = await sorobanService.buildLiquidateTx( - liquidatorPublicKey, - loanIdNum, + if (liquidatorPublicKey !== req.user?.publicKey) { + throw AppError.forbidden( + 'liquidatorPublicKey must match your authenticated wallet', + ErrorCode.ACCESS_DENIED, ); + } - await cacheService.set(cacheKey, result, 60); + const loanIdNum = Number.parseInt(loanId, 10); + if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) { + throw AppError.badRequest('Invalid loan ID', ErrorCode.INVALID_LOAN_ID, 'loanId'); + } - logger.withContext().info("Liquidate loan transaction built", { + const cacheKey = `pending_liquidate_tx:${liquidatorPublicKey}:${loanIdNum}`; + const cachedTx = await cacheService.get<{ + unsignedTxXdr: string; + networkPassphrase: string; + }>(cacheKey); + + if (cachedTx) { + logger.withContext().info('Returning cached unsigned liquidate tx', { liquidator: liquidatorPublicKey, loanId: loanIdNum, }); - res.json({ success: true, loanId: loanIdNum, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, + unsignedTxXdr: cachedTx.unsignedTxXdr, + networkPassphrase: cachedTx.networkPassphrase, }); - }, -); + return; + } + + const result = await sorobanService.buildLiquidateTx(liquidatorPublicKey, loanIdNum); + + await cacheService.set(cacheKey, result, 60); + + logger.withContext().info('Liquidate loan transaction built', { + liquidator: liquidatorPublicKey, + loanId: loanIdNum, + }); + + res.json({ + success: true, + loanId: loanIdNum, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); /** * POST /api/loans/submit * POST /api/loans/:loanId/submit */ -export const submitTransaction = asyncHandler( - async (req: Request, res: Response) => { - const { signedTxXdr } = req.body as { signedTxXdr: string }; +export const submitTransaction = asyncHandler(async (req: Request, res: Response) => { + const { signedTxXdr } = req.body as { signedTxXdr: string }; - if (!signedTxXdr) { - throw AppError.badRequest( - "signedTxXdr is required", - ErrorCode.MISSING_FIELD, - "signedTxXdr", - ); - } + if (!signedTxXdr) { + throw AppError.badRequest('signedTxXdr is required', ErrorCode.MISSING_FIELD, 'signedTxXdr'); + } - // Use transaction wrapper for consistency with multi-step operations - const result = await withStellarAndDbTransaction( - // Stellar operation - async () => { - return await sorobanService.submitSignedTx(signedTxXdr); - }, - // Database operations (currently none, but structured for future use) - async (stellarResult: unknown, client) => { - const sr = stellarResult as { txHash: string; status: string }; - await client.query( - `INSERT INTO transaction_submissions (tx_hash, status, submitted_at, submitted_by) + // Use transaction wrapper for consistency with multi-step operations + const result = await withStellarAndDbTransaction( + // Stellar operation + async () => { + return await sorobanService.submitSignedTx(signedTxXdr); + }, + // Database operations (currently none, but structured for future use) + async (stellarResult: unknown, client) => { + const sr = stellarResult as { txHash: string; status: string }; + await client.query( + `INSERT INTO transaction_submissions (tx_hash, status, submitted_at, submitted_by) VALUES ($1, $2, NOW(), $3) ON CONFLICT (tx_hash) DO UPDATE SET status = EXCLUDED.status, submitted_at = EXCLUDED.submitted_at`, - [sr.txHash, sr.status, req.user?.publicKey || null], - ); + [sr.txHash, sr.status, req.user?.publicKey || null], + ); - logger.withContext().info("Transaction submission recorded", { - txHash: sr.txHash, - status: sr.status, - submittedBy: req.user?.publicKey, - }); + logger.withContext().info('Transaction submission recorded', { + txHash: sr.txHash, + status: sr.status, + submittedBy: req.user?.publicKey, + }); - return { recorded: true }; - }, - ); + return { recorded: true }; + }, + ); - const sr = result.stellarResult as { - txHash: string; - status: string; - resultXdr?: string; - }; + const sr = result.stellarResult as { + txHash: string; + status: string; + resultXdr?: string; + }; - logger.withContext().info("Transaction submitted successfully", { - txHash: sr.txHash, - status: sr.status, - }); + logger.withContext().info('Transaction submitted successfully', { + txHash: sr.txHash, + status: sr.status, + }); - res.json({ - success: true, - txHash: sr.txHash, - status: sr.status, - ...(sr.resultXdr ? { resultXdr: sr.resultXdr } : {}), - }); - }, -); + res.json({ + success: true, + txHash: sr.txHash, + status: sr.status, + ...(sr.resultXdr ? { resultXdr: sr.resultXdr } : {}), + }); +}); diff --git a/backend/src/controllers/notificationController.ts b/backend/src/controllers/notificationController.ts index b4b92224..d6adf56c 100644 --- a/backend/src/controllers/notificationController.ts +++ b/backend/src/controllers/notificationController.ts @@ -1,13 +1,13 @@ -import { Request, Response } from "express"; -import { notificationService } from "../services/notificationService.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { AppError } from "../errors/AppError.js"; -import { parseCappedLimit } from "../utils/queryHelpers.js"; -import logger from "../utils/logger.js"; +import { Request, Response } from 'express'; +import { notificationService } from '../services/notificationService.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { AppError } from '../errors/AppError.js'; +import { parseCappedLimit } from '../utils/queryHelpers.js'; +import logger from '../utils/logger.js'; import { updateNotificationPreferencesSchema, validateNotificationPhone, -} from "../schemas/notificationSchemas.js"; +} from '../schemas/notificationSchemas.js'; /** * GET /api/notifications @@ -15,40 +15,31 @@ import { * Supports filtering by type, status, and date range. * Optional query params: ?type=X&status=Y&from=ISO&to=ISO&limit=N (default 50, max 100) */ -export const getNotifications = asyncHandler( - async (req: Request, res: Response) => { - const userId = req.user?.publicKey; - if (!userId) throw AppError.unauthorized("Authentication required"); - - const limit = parseCappedLimit(req, 50); - const type = req.query.type as string | undefined; - const status = req.query.status as string | undefined; - const from = req.query.from as string | undefined; - const to = req.query.to as string | undefined; - - // Validate date formats - if (from && Number.isNaN(Date.parse(from))) { - throw AppError.badRequest("Invalid 'from' date format"); - } - if (to && Number.isNaN(Date.parse(to))) { - throw AppError.badRequest("Invalid 'to' date format"); - } +export const getNotifications = asyncHandler(async (req: Request, res: Response) => { + const userId = req.user?.publicKey; + if (!userId) throw AppError.unauthorized('Authentication required'); - const [notifications, unreadCount] = await Promise.all([ - notificationService.getNotificationsForUser( - userId, - limit, - type, - status, - from, - to, - ), - notificationService.getUnreadCount(userId), - ]); - - res.json({ success: true, data: { notifications, unreadCount } }); - }, -); + const limit = parseCappedLimit(req, 50); + const type = req.query.type as string | undefined; + const status = req.query.status as string | undefined; + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + + // Validate date formats + if (from && Number.isNaN(Date.parse(from))) { + throw AppError.badRequest("Invalid 'from' date format"); + } + if (to && Number.isNaN(Date.parse(to))) { + throw AppError.badRequest("Invalid 'to' date format"); + } + + const [notifications, unreadCount] = await Promise.all([ + notificationService.getNotificationsForUser(userId, limit, type, status, from, to), + notificationService.getUnreadCount(userId), + ]); + + res.json({ success: true, data: { notifications, unreadCount } }); +}); /** * POST /api/notifications/mark-read @@ -57,11 +48,11 @@ export const getNotifications = asyncHandler( */ export const markRead = asyncHandler(async (req: Request, res: Response) => { const userId = req.user?.publicKey; - if (!userId) throw AppError.unauthorized("Authentication required"); + if (!userId) throw AppError.unauthorized('Authentication required'); const { ids } = req.body as { ids?: unknown }; - if (!Array.isArray(ids) || ids.some((id) => typeof id !== "number")) { - throw AppError.badRequest("Body must contain an array of numeric ids"); + if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'number')) { + throw AppError.badRequest('Body must contain an array of numeric ids'); } await notificationService.markRead(userId, ids as number[]); @@ -74,53 +65,43 @@ export const markRead = asyncHandler(async (req: Request, res: Response) => { */ export const markAllRead = asyncHandler(async (req: Request, res: Response) => { const userId = req.user?.publicKey; - if (!userId) throw AppError.unauthorized("Authentication required"); + if (!userId) throw AppError.unauthorized('Authentication required'); await notificationService.markAllRead(userId); res.json({ success: true }); }); -export const getNotificationPreferences = asyncHandler( - async (req: Request, res: Response) => { - const userId = req.user?.publicKey; - if (!userId) throw AppError.unauthorized("Authentication required"); +export const getNotificationPreferences = asyncHandler(async (req: Request, res: Response) => { + const userId = req.user?.publicKey; + if (!userId) throw AppError.unauthorized('Authentication required'); - const preferences = - await notificationService.getNotificationPreferences(userId); - res.json(preferences); - }, -); + const preferences = await notificationService.getNotificationPreferences(userId); + res.json(preferences); +}); -export const updateNotificationPreferences = asyncHandler( - async (req: Request, res: Response) => { - const userId = req.user?.publicKey; - if (!userId) throw AppError.unauthorized("Authentication required"); +export const updateNotificationPreferences = asyncHandler(async (req: Request, res: Response) => { + const userId = req.user?.publicKey; + if (!userId) throw AppError.unauthorized('Authentication required'); - const parsed = updateNotificationPreferencesSchema.parse(req.body); - const phone = parsed.phone?.trim() ? parsed.phone.trim() : null; + const parsed = updateNotificationPreferencesSchema.parse(req.body); + const phone = parsed.phone?.trim() ? parsed.phone.trim() : null; - if (!validateNotificationPhone(phone)) { - throw AppError.badRequest("Phone must be a valid E.164-like number"); - } + if (!validateNotificationPhone(phone)) { + throw AppError.badRequest('Phone must be a valid E.164-like number'); + } - if (parsed.smsEnabled && !phone) { - throw AppError.badRequest( - "Phone is required when SMS notifications are enabled", - ); - } + if (parsed.smsEnabled && !phone) { + throw AppError.badRequest('Phone is required when SMS notifications are enabled'); + } - const preferences = await notificationService.updateNotificationPreferences( - userId, - { - emailEnabled: parsed.emailEnabled, - smsEnabled: parsed.smsEnabled, - phone, - }, - ); + const preferences = await notificationService.updateNotificationPreferences(userId, { + emailEnabled: parsed.emailEnabled, + smsEnabled: parsed.smsEnabled, + phone, + }); - res.json(preferences); - }, -); + res.json(preferences); +}); /** * GET /api/notifications/stream @@ -128,53 +109,46 @@ export const updateNotificationPreferences = asyncHandler( * The client connects and keeps the connection open; whenever the user * receives a new notification the server pushes it as a JSON `data:` event. */ -export const streamNotifications = asyncHandler( - async (req: Request, res: Response) => { - const userId = req.user?.publicKey; - if (!userId) throw AppError.unauthorized("Authentication required"); - - // SSE headers - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("X-Accel-Buffering", "no"); // disable nginx buffering - res.flushHeaders(); - - // Send a comment heartbeat every 30s to keep the connection alive through - // load balancers and proxies. - const heartbeat = setInterval(() => { - try { - res.write(": heartbeat\n\n"); - } catch { - // client already gone - } - }, 30_000); - - // Send any unread notifications immediately on connect so the client - // doesn't have to issue a separate GET. +export const streamNotifications = asyncHandler(async (req: Request, res: Response) => { + const userId = req.user?.publicKey; + if (!userId) throw AppError.unauthorized('Authentication required'); + + // SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering + res.flushHeaders(); + + // Send a comment heartbeat every 30s to keep the connection alive through + // load balancers and proxies. + const heartbeat = setInterval(() => { try { - const notifications = await notificationService.getNotificationsForUser( - userId, - 50, - ); - const unread = notifications.filter((n) => !n.read); - if (unread.length) { - res.write( - `data: ${JSON.stringify({ type: "init", notifications: unread })}\n\n`, - ); - } - } catch (err) { - logger.withContext().error("SSE init fetch error", { userId, err }); + res.write(': heartbeat\n\n'); + } catch { + // client already gone + } + }, 30_000); + + // Send any unread notifications immediately on connect so the client + // doesn't have to issue a separate GET. + try { + const notifications = await notificationService.getNotificationsForUser(userId, 50); + const unread = notifications.filter((n) => !n.read); + if (unread.length) { + res.write(`data: ${JSON.stringify({ type: 'init', notifications: unread })}\n\n`); } + } catch (err) { + logger.withContext().error('SSE init fetch error', { userId, err }); + } - const unsubscribe = notificationService.subscribe(userId, res); + const unsubscribe = notificationService.subscribe(userId, res); - const cleanup = () => { - clearInterval(heartbeat); - unsubscribe(); - }; + const cleanup = () => { + clearInterval(heartbeat); + unsubscribe(); + }; - req.on("close", cleanup); - req.on("error", cleanup); - }, -); + req.on('close', cleanup); + req.on('error', cleanup); +}); diff --git a/backend/src/controllers/poolController.ts b/backend/src/controllers/poolController.ts index bc9ce2a2..177cbb37 100644 --- a/backend/src/controllers/poolController.ts +++ b/backend/src/controllers/poolController.ts @@ -1,20 +1,17 @@ -import { Request, Response } from "express"; -import { query } from "../db/connection.js"; -import { withStellarAndDbTransaction } from "../db/transaction.js"; -import { AppError } from "../errors/AppError.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { sorobanService } from "../services/sorobanService.js"; -import { cacheService } from "../services/cacheService.js"; +import { Request, Response } from 'express'; +import { query } from '../db/connection.js'; +import { withStellarAndDbTransaction } from '../db/transaction.js'; +import { AppError } from '../errors/AppError.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { sorobanService } from '../services/sorobanService.js'; +import { cacheService } from '../services/cacheService.js'; import { buildDepositorYieldHistory, computeApy, normalizeYieldHistoryDays, -} from "../services/yieldHistoryService.js"; -import logger from "../utils/logger.js"; -import { - invalidateOnDeposit, - invalidateOnWithdraw, -} from "../utils/cacheKeys.js"; +} from '../services/yieldHistoryService.js'; +import logger from '../utils/logger.js'; +import { invalidateOnDeposit, invalidateOnWithdraw } from '../utils/cacheKeys.js'; /** * The on-chain share price is scaled by SHARE_PRICE_SCALE (1,000,000 = 1.0). @@ -42,11 +39,9 @@ function safeFloat(value: unknown, fallback = 0): number { * GET /api/pool/stats * Returns aggregate pool statistics for the lender dashboard. */ -export const getPoolStats = asyncHandler( - async (_req: Request, res: Response) => { - const [depositResult, loanResult, withdrawalCooldownLedgers] = - await Promise.all([ - query(` +export const getPoolStats = asyncHandler(async (_req: Request, res: Response) => { + const [depositResult, loanResult, withdrawalCooldownLedgers] = await Promise.all([ + query(` SELECT COALESCE(SUM(CASE WHEN event_type = 'Deposit' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN event_type = 'Withdraw' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) @@ -54,7 +49,7 @@ export const getPoolStats = asyncHandler( FROM contract_events WHERE event_type IN ('Deposit', 'Withdraw') `), - query(` + query(` SELECT COALESCE(COUNT(DISTINCT loan_id) FILTER ( WHERE event_type = 'LoanApproved' @@ -65,44 +60,39 @@ export const getPoolStats = asyncHandler( FROM contract_events WHERE event_type IN ('LoanApproved', 'LoanRepaid') `), - sorobanService.getWithdrawalCooldownLedgers().catch(() => 0), - ]); - - const totalDeposits = safeFloat(depositResult.rows[0]?.total_deposits); - const totalOutstanding = safeFloat(loanResult.rows[0]?.total_outstanding); - const activeLoansCount = Math.trunc( - safeFloat(loanResult.rows[0]?.active_loans_count), - ); - - const utilizationRate = - totalDeposits > 0 ? Math.min(totalOutstanding / totalDeposits, 1) : 0; - - res.json({ - success: true, - data: { - totalDeposits, - totalOutstanding, - utilizationRate: parseFloat(utilizationRate.toFixed(4)), - apy: ANNUAL_APY, - activeLoansCount, - poolTokenAddress: process.env.POOL_TOKEN_ADDRESS, - withdrawalCooldownLedgers, - }, - }); - }, -); + sorobanService.getWithdrawalCooldownLedgers().catch(() => 0), + ]); + + const totalDeposits = safeFloat(depositResult.rows[0]?.total_deposits); + const totalOutstanding = safeFloat(loanResult.rows[0]?.total_outstanding); + const activeLoansCount = Math.trunc(safeFloat(loanResult.rows[0]?.active_loans_count)); + + const utilizationRate = totalDeposits > 0 ? Math.min(totalOutstanding / totalDeposits, 1) : 0; + + res.json({ + success: true, + data: { + totalDeposits, + totalOutstanding, + utilizationRate: parseFloat(utilizationRate.toFixed(4)), + apy: ANNUAL_APY, + activeLoansCount, + poolTokenAddress: process.env.POOL_TOKEN_ADDRESS, + withdrawalCooldownLedgers, + }, + }); +}); /** * GET /api/pool/depositor/:address * Returns portfolio details for a specific depositor address. */ -export const getDepositorPortfolio = asyncHandler( - async (req: Request, res: Response) => { - const { address } = req.params; +export const getDepositorPortfolio = asyncHandler(async (req: Request, res: Response) => { + const { address } = req.params; - const [depositorResult, poolTotalResult] = await Promise.all([ - query( - ` + const [depositorResult, poolTotalResult] = await Promise.all([ + query( + ` SELECT COALESCE(SUM(CASE WHEN event_type = 'Deposit' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN event_type = 'Withdraw' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) @@ -113,9 +103,9 @@ export const getDepositorPortfolio = asyncHandler( WHERE event_type IN ('Deposit', 'Withdraw') AND address = $1 `, - [address], - ), - query(` + [address], + ), + query(` SELECT COALESCE(SUM(CASE WHEN event_type = 'Deposit' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN event_type = 'Withdraw' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) @@ -123,339 +113,291 @@ export const getDepositorPortfolio = asyncHandler( FROM contract_events WHERE event_type IN ('Deposit', 'Withdraw') `), - ]); + ]); - const depositAmount = safeFloat(depositorResult.rows[0]?.deposit_amount); - const poolTotal = safeFloat(poolTotalResult.rows[0]?.pool_total); - const firstDepositAt = depositorResult.rows[0]?.first_deposit_at ?? null; - const lastDepositAt = depositorResult.rows[0]?.last_deposit_at ?? null; + const depositAmount = safeFloat(depositorResult.rows[0]?.deposit_amount); + const poolTotal = safeFloat(poolTotalResult.rows[0]?.pool_total); + const firstDepositAt = depositorResult.rows[0]?.first_deposit_at ?? null; + const lastDepositAt = depositorResult.rows[0]?.last_deposit_at ?? null; - const sharePercent = poolTotal > 0 ? depositAmount / poolTotal : 0; + const sharePercent = poolTotal > 0 ? depositAmount / poolTotal : 0; - const daysDeposited = firstDepositAt - ? Math.max( - 0, - (Date.now() - new Date(firstDepositAt).getTime()) / - (1000 * 60 * 60 * 24), - ) - : 0; + const daysDeposited = firstDepositAt + ? Math.max(0, (Date.now() - new Date(firstDepositAt).getTime()) / (1000 * 60 * 60 * 24)) + : 0; - const estimatedYield = depositAmount * ANNUAL_APY * (daysDeposited / 365); + const estimatedYield = depositAmount * ANNUAL_APY * (daysDeposited / 365); - res.json({ - success: true, - data: { - address, - depositAmount, - sharePercent: parseFloat(sharePercent.toFixed(6)), - estimatedYield: parseFloat(estimatedYield.toFixed(7)), - apy: ANNUAL_APY, - firstDepositAt, - lastDepositAt, - }, - }); - }, -); + res.json({ + success: true, + data: { + address, + depositAmount, + sharePercent: parseFloat(sharePercent.toFixed(6)), + estimatedYield: parseFloat(estimatedYield.toFixed(7)), + apy: ANNUAL_APY, + firstDepositAt, + lastDepositAt, + }, + }); +}); /** * GET /api/pool/depositor/:address/yield-history * Returns a time series of depositor yield reconstructed from indexed pool events. */ -export const getDepositorYieldHistory = asyncHandler( - async (req: Request, res: Response) => { - const address = req.params.address as string; - const days = normalizeYieldHistoryDays( - req.query.days ? Number(req.query.days) : undefined, - ); - const token = - typeof req.query.token === "string" && req.query.token.length > 0 - ? req.query.token - : process.env.POOL_TOKEN_ADDRESS; - - if (!token) { - throw AppError.internal("POOL_TOKEN_ADDRESS is not configured"); - } - - let currentSharePrice: number | undefined; - try { - currentSharePrice = await sorobanService.getSharePrice(token); - } catch (error) { - logger.warn("Could not fetch on-chain share price for yield history", { - address, - error: error instanceof Error ? error.message : String(error), - }); - } - - const history = await buildDepositorYieldHistory( +export const getDepositorYieldHistory = asyncHandler(async (req: Request, res: Response) => { + const address = req.params.address as string; + const days = normalizeYieldHistoryDays(req.query.days ? Number(req.query.days) : undefined); + const token = + typeof req.query.token === 'string' && req.query.token.length > 0 + ? req.query.token + : process.env.POOL_TOKEN_ADDRESS; + + if (!token) { + throw AppError.internal('POOL_TOKEN_ADDRESS is not configured'); + } + + let currentSharePrice: number | undefined; + try { + currentSharePrice = await sorobanService.getSharePrice(token); + } catch (error) { + logger.warn('Could not fetch on-chain share price for yield history', { address, - token, - days, - currentSharePrice, - ); + error: error instanceof Error ? error.message : String(error), + }); + } + + const history = await buildDepositorYieldHistory(address, token, days, currentSharePrice); + + const firstTimestamp = history[0]?.timestamp; + const daysElapsed = firstTimestamp + ? Math.max(1, (Date.now() - new Date(firstTimestamp).getTime()) / (1000 * 60 * 60 * 24)) + : 1; + + const data = history.map((point) => ({ + timestamp: point.timestamp, + depositedValue: point.depositedValue, + currentValue: point.currentValue, + netYield: point.netYield, + date: point.timestamp, + earnings: point.netYield, + principal: point.depositedValue, + apy: computeApy(point.netYield, point.depositedValue, daysElapsed), + })); - const firstTimestamp = history[0]?.timestamp; - const daysElapsed = firstTimestamp - ? Math.max( - 1, - (Date.now() - new Date(firstTimestamp).getTime()) / - (1000 * 60 * 60 * 24), - ) - : 1; - - const data = history.map((point) => ({ - timestamp: point.timestamp, - depositedValue: point.depositedValue, - currentValue: point.currentValue, - netYield: point.netYield, - date: point.timestamp, - earnings: point.netYield, - principal: point.depositedValue, - apy: computeApy(point.netYield, point.depositedValue, daysElapsed), - })); - - res.json({ success: true, data }); - }, -); + res.json({ success: true, data }); +}); /** * POST /api/pool/build-deposit * Build an unsigned LendingPool deposit transaction. */ -export const depositToPool = asyncHandler( - async (req: Request, res: Response) => { - const { depositorPublicKey, token, amount } = req.body as { - depositorPublicKey: string; - token: string; - amount: number; - }; - - if (!depositorPublicKey || !token || !amount || amount <= 0) { - throw AppError.badRequest( - "depositorPublicKey, token, and a positive amount are required", - ); - } - - if (depositorPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "depositorPublicKey must match your authenticated wallet", - ); - } - - const result = await sorobanService.buildDepositTx( - depositorPublicKey, - token, - amount, - ); - - // Invalidate stale pool stats cache now that a deposit has been initiated - await invalidateOnDeposit(depositorPublicKey); - - logger.withContext().info("Deposit transaction built", { - depositor: depositorPublicKey, - token, - amount, - }); - - res.json({ - success: true, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, - }); - }, -); +export const depositToPool = asyncHandler(async (req: Request, res: Response) => { + const { depositorPublicKey, token, amount } = req.body as { + depositorPublicKey: string; + token: string; + amount: number; + }; + + if (!depositorPublicKey || !token || !amount || amount <= 0) { + throw AppError.badRequest('depositorPublicKey, token, and a positive amount are required'); + } + + if (depositorPublicKey !== req.user?.publicKey) { + throw AppError.forbidden('depositorPublicKey must match your authenticated wallet'); + } + + const result = await sorobanService.buildDepositTx(depositorPublicKey, token, amount); + + // Invalidate stale pool stats cache now that a deposit has been initiated + await invalidateOnDeposit(depositorPublicKey); + + logger.withContext().info('Deposit transaction built', { + depositor: depositorPublicKey, + token, + amount, + }); + + res.json({ + success: true, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); /** * POST /api/pool/build-withdraw * Build an unsigned LendingPool withdraw transaction. */ -export const withdrawFromPool = asyncHandler( - async (req: Request, res: Response) => { - const { depositorPublicKey, token, amount } = req.body as { - depositorPublicKey: string; - token: string; - amount: number; - }; - - // Note: 'amount' here refers to shares to withdraw. - if (!depositorPublicKey || !token || !amount || amount <= 0) { - throw AppError.badRequest( - "depositorPublicKey, token, and a positive amount (shares) are required", - ); - } +export const withdrawFromPool = asyncHandler(async (req: Request, res: Response) => { + const { depositorPublicKey, token, amount } = req.body as { + depositorPublicKey: string; + token: string; + amount: number; + }; + + // Note: 'amount' here refers to shares to withdraw. + if (!depositorPublicKey || !token || !amount || amount <= 0) { + throw AppError.badRequest( + 'depositorPublicKey, token, and a positive amount (shares) are required', + ); + } - if (depositorPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "depositorPublicKey must match your authenticated wallet", - ); - } + if (depositorPublicKey !== req.user?.publicKey) { + throw AppError.forbidden('depositorPublicKey must match your authenticated wallet'); + } - const result = await sorobanService.buildWithdrawTx( - depositorPublicKey, - token, - amount, - ); + const result = await sorobanService.buildWithdrawTx(depositorPublicKey, token, amount); - // Invalidate stale pool stats cache now that a withdrawal has been initiated - await invalidateOnWithdraw(depositorPublicKey); + // Invalidate stale pool stats cache now that a withdrawal has been initiated + await invalidateOnWithdraw(depositorPublicKey); - logger.withContext().info("Withdraw transaction built", { - depositor: depositorPublicKey, - token, - shares: amount, - }); + logger.withContext().info('Withdraw transaction built', { + depositor: depositorPublicKey, + token, + shares: amount, + }); - res.json({ - success: true, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, - }); - }, -); + res.json({ + success: true, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); /** * POST /api/pool/build-emergency-withdraw * Build an unsigned LendingPool emergency_withdraw transaction. */ -export const emergencyWithdrawFromPool = asyncHandler( - async (req: Request, res: Response) => { - const { depositorPublicKey, token, shares } = req.body as { - depositorPublicKey: string; - token: string; - shares: number; - }; - - if (!depositorPublicKey || !token || !shares || shares <= 0) { - throw AppError.badRequest( - "depositorPublicKey, token, and a positive shares amount are required", - ); - } +export const emergencyWithdrawFromPool = asyncHandler(async (req: Request, res: Response) => { + const { depositorPublicKey, token, shares } = req.body as { + depositorPublicKey: string; + token: string; + shares: number; + }; + + if (!depositorPublicKey || !token || !shares || shares <= 0) { + throw AppError.badRequest( + 'depositorPublicKey, token, and a positive shares amount are required', + ); + } - if (depositorPublicKey !== req.user?.publicKey) { - throw AppError.forbidden( - "depositorPublicKey must match your authenticated wallet", - ); - } + if (depositorPublicKey !== req.user?.publicKey) { + throw AppError.forbidden('depositorPublicKey must match your authenticated wallet'); + } - const result = await sorobanService.buildEmergencyWithdrawTx( - depositorPublicKey, - token, - shares, - ); + const result = await sorobanService.buildEmergencyWithdrawTx(depositorPublicKey, token, shares); - logger.info("Emergency withdraw transaction built", { - depositor: depositorPublicKey, - token, - shares, - }); + logger.info('Emergency withdraw transaction built', { + depositor: depositorPublicKey, + token, + shares, + }); - res.json({ - success: true, - unsignedTxXdr: result.unsignedTxXdr, - networkPassphrase: result.networkPassphrase, - }); - }, -); + res.json({ + success: true, + unsignedTxXdr: result.unsignedTxXdr, + networkPassphrase: result.networkPassphrase, + }); +}); /** * POST /api/pool/submit * Submit a signed pool transaction to the Stellar network. */ -export const submitPoolTransaction = asyncHandler( - async (req: Request, res: Response) => { - const { signedTxXdr } = req.body as { signedTxXdr: string }; - - if (!signedTxXdr) { - throw AppError.badRequest("signedTxXdr is required"); - } - - // Use transaction wrapper for consistency with multi-step operations - const result = await withStellarAndDbTransaction( - // Stellar operation - async () => { - return await sorobanService.submitSignedTx(signedTxXdr); - }, - // Database operations (currently none, but structured for future use) - async (stellarResult: unknown, client) => { - const sr = stellarResult as { txHash: string; status: string }; - await client.query( - `INSERT INTO transaction_submissions (tx_hash, status, submitted_at, submitted_by, transaction_type) +export const submitPoolTransaction = asyncHandler(async (req: Request, res: Response) => { + const { signedTxXdr } = req.body as { signedTxXdr: string }; + + if (!signedTxXdr) { + throw AppError.badRequest('signedTxXdr is required'); + } + + // Use transaction wrapper for consistency with multi-step operations + const result = await withStellarAndDbTransaction( + // Stellar operation + async () => { + return await sorobanService.submitSignedTx(signedTxXdr); + }, + // Database operations (currently none, but structured for future use) + async (stellarResult: unknown, client) => { + const sr = stellarResult as { txHash: string; status: string }; + await client.query( + `INSERT INTO transaction_submissions (tx_hash, status, submitted_at, submitted_by, transaction_type) VALUES ($1, $2, NOW(), $3, $4) ON CONFLICT (tx_hash) DO UPDATE SET status = EXCLUDED.status, submitted_at = EXCLUDED.submitted_at`, - [sr.txHash, sr.status, req.user?.publicKey || null, "pool"], - ); - - logger.withContext().info("Pool transaction submission recorded", { - txHash: sr.txHash, - status: sr.status, - submittedBy: req.user?.publicKey, - transactionType: "pool", - }); - - return { recorded: true }; - }, - ); + [sr.txHash, sr.status, req.user?.publicKey || null, 'pool'], + ); - const sr = result.stellarResult as { - txHash: string; - status: string; - resultXdr?: string; - }; + logger.withContext().info('Pool transaction submission recorded', { + txHash: sr.txHash, + status: sr.status, + submittedBy: req.user?.publicKey, + transactionType: 'pool', + }); - logger.withContext().info("Pool transaction submitted successfully", { - txHash: sr.txHash, - status: sr.status, - }); - - res.json({ - success: true, - txHash: sr.txHash, - status: sr.status, - ...(sr.resultXdr ? { resultXdr: sr.resultXdr } : {}), - }); - }, -); + return { recorded: true }; + }, + ); + + const sr = result.stellarResult as { + txHash: string; + status: string; + resultXdr?: string; + }; + + logger.withContext().info('Pool transaction submitted successfully', { + txHash: sr.txHash, + status: sr.status, + }); + + res.json({ + success: true, + txHash: sr.txHash, + status: sr.status, + ...(sr.resultXdr ? { resultXdr: sr.resultXdr } : {}), + }); +}); /** * GET /api/pool/:token/share-price * Returns the current on-chain share price for the given token. * Cached briefly to absorb burst requests. */ -export const getPoolSharePrice = asyncHandler( - async (req: Request, res: Response) => { - const token = req.params.token as string; - - if (!token) { - throw AppError.badRequest("Token address is required"); - } - - const cacheKey = `pool:share-price:${token}`; - const cached = await cacheService.get<{ - sharePrice: number; - sharePriceRatio: number; - }>(cacheKey); - - if (cached !== null) { - res.json({ - success: true, - data: cached, - cached: true, - }); - return; - } +export const getPoolSharePrice = asyncHandler(async (req: Request, res: Response) => { + const token = req.params.token as string; - const sharePrice = await sorobanService.getSharePrice(token); - const sharePriceRatio = sharePrice / SHARE_PRICE_SCALE; + if (!token) { + throw AppError.badRequest('Token address is required'); + } - const data = { sharePrice, sharePriceRatio }; - - await cacheService.set(cacheKey, data, SHARE_PRICE_CACHE_TTL_SECONDS); + const cacheKey = `pool:share-price:${token}`; + const cached = await cacheService.get<{ + sharePrice: number; + sharePriceRatio: number; + }>(cacheKey); + if (cached !== null) { res.json({ success: true, - data, - cached: false, + data: cached, + cached: true, }); - }, -); + return; + } + + const sharePrice = await sorobanService.getSharePrice(token); + const sharePriceRatio = sharePrice / SHARE_PRICE_SCALE; + + const data = { sharePrice, sharePriceRatio }; + + await cacheService.set(cacheKey, data, SHARE_PRICE_CACHE_TTL_SECONDS); + + res.json({ + success: true, + data, + cached: false, + }); +}); diff --git a/backend/src/controllers/remittanceController.ts b/backend/src/controllers/remittanceController.ts index 148c2a87..a8e7b4e3 100644 --- a/backend/src/controllers/remittanceController.ts +++ b/backend/src/controllers/remittanceController.ts @@ -1,11 +1,11 @@ -import type { Request, Response } from "express"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { remittanceService } from "../services/remittanceService.js"; -import { sorobanService } from "../services/sorobanService.js"; -import { notificationService } from "../services/notificationService.js"; -import { AppError } from "../errors/AppError.js"; -import { parseCursorQueryParams } from "../utils/pagination.js"; -import logger from "../utils/logger.js"; +import type { Request, Response } from 'express'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { remittanceService } from '../services/remittanceService.js'; +import { sorobanService } from '../services/sorobanService.js'; +import { notificationService } from '../services/notificationService.js'; +import { AppError } from '../errors/AppError.js'; +import { parseCursorQueryParams } from '../utils/pagination.js'; +import logger from '../utils/logger.js'; /** * POST /api/remittances - Create a new remittance @@ -13,42 +13,38 @@ import logger from "../utils/logger.js"; * Creates an unsigned Stellar transaction for the frontend to sign * with Freighter wallet. Returns XDR for preview and signing. */ -export const createRemittance = asyncHandler( - async (req: Request, res: Response) => { - const { recipientAddress, amount, fromCurrency, toCurrency, memo } = - req.body; +export const createRemittance = asyncHandler(async (req: Request, res: Response) => { + const { recipientAddress, amount, fromCurrency, toCurrency, memo } = req.body; // Get sender address from JWT (added by requireJwtAuth middleware) const senderAddress = req.user?.publicKey; - if (!senderAddress) { - throw AppError.unauthorized("Wallet address not found in request"); - } - - logger.withContext().info("Creating remittance", { - sender: senderAddress, - recipient: recipientAddress, - amount, - currency: fromCurrency, - }); - - const remittance = await remittanceService.createRemittance({ - recipientAddress, - amount, - fromCurrency, - toCurrency, - memo, - senderAddress, - }); - - res.status(201).json({ - success: true, - data: remittance, - message: - "Remittance created successfully. Sign the transaction in your wallet.", - }); - }, -); + if (!senderAddress) { + throw AppError.unauthorized('Wallet address not found in request'); + } + + logger.withContext().info('Creating remittance', { + sender: senderAddress, + recipient: recipientAddress, + amount, + currency: fromCurrency, + }); + + const remittance = await remittanceService.createRemittance({ + recipientAddress, + amount, + fromCurrency, + toCurrency, + memo, + senderAddress, + }); + + res.status(201).json({ + success: true, + data: remittance, + message: 'Remittance created successfully. Sign the transaction in your wallet.', + }); +}); /** * GET /api/remittances - Get user's remittances @@ -60,38 +56,37 @@ export const getRemittances = asyncHandler( async (req: Request, res: Response) => { const senderAddress = req.user?.publicKey as string; - if (!senderAddress) { - throw AppError.unauthorized("Wallet address not found in request"); - } - - const { limit, cursor } = parseCursorQueryParams(req); - const status = req.query.status as string | undefined; - const from = req.query.from as string | undefined; - const to = req.query.to as string | undefined; - const q = req.query.q as string | undefined; - - const result = await remittanceService.getRemittances( - senderAddress, + if (!senderAddress) { + throw AppError.unauthorized('Wallet address not found in request'); + } + + const { limit, cursor } = parseCursorQueryParams(req); + const status = req.query.status as string | undefined; + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + const q = req.query.q as string | undefined; + + const result = await remittanceService.getRemittances( + senderAddress, + limit, + cursor, + status, + from, + to, + q, + ); + + res.json({ + success: true, + data: result.remittances, + page_info: { limit, - cursor, - status, - from, - to, - q, - ); - - res.json({ - success: true, - data: result.remittances, - page_info: { - limit, - next_cursor: result.nextCursor, - has_next: result.nextCursor !== null, - total: result.total, - }, - }); - }, -); + next_cursor: result.nextCursor, + has_next: result.nextCursor !== null, + total: result.total, + }, + }); +}); /** * GET /api/remittances/:id - Get a single remittance @@ -103,27 +98,26 @@ export const getRemittance = asyncHandler( const { id } = req.params as { id: string }; const senderAddress = req.user?.publicKey as string; - if (!senderAddress) { - throw AppError.unauthorized("Wallet address not found in request"); - } + if (!senderAddress) { + throw AppError.unauthorized('Wallet address not found in request'); + } - if (!id) { - throw AppError.badRequest("Remittance ID is required"); - } + if (!id) { + throw AppError.badRequest('Remittance ID is required'); + } - const remittance = await remittanceService.getRemittance(id); + const remittance = await remittanceService.getRemittance(id); - // Verify the user owns this remittance - if (remittance.senderId !== senderAddress) { - throw AppError.forbidden("You do not have access to this remittance"); - } + // Verify the user owns this remittance + if (remittance.senderId !== senderAddress) { + throw AppError.forbidden('You do not have access to this remittance'); + } - res.json({ - success: true, - data: remittance, - }); - }, -); + res.json({ + success: true, + data: remittance, + }); +}); /** * POST /api/remittances/:id/submit - Submit signed transaction @@ -136,85 +130,80 @@ export const submitRemittanceTransaction = asyncHandler( const { signedXdr } = req.body as { signedXdr: string }; const senderAddress = req.user?.publicKey as string; - if (!senderAddress) { - throw AppError.unauthorized("Wallet address not found in request"); - } + if (!senderAddress) { + throw AppError.unauthorized('Wallet address not found in request'); + } + + if (!signedXdr) { + throw AppError.badRequest('Signed XDR is required'); + } + + if (!id) { + throw AppError.badRequest('Remittance ID is required'); + } - if (!signedXdr) { - throw AppError.badRequest("Signed XDR is required"); + logger.withContext().info('Submitting remittance transaction', { remittanceId: id }); + + try { + const remittance = await remittanceService.getRemittance(id); + + if (remittance.senderId !== senderAddress) { + throw AppError.forbidden('You do not have access to this remittance'); } - if (!id) { - throw AppError.badRequest("Remittance ID is required"); + if (remittance.status !== 'pending') { + throw AppError.badRequest('Remittance has already been submitted'); } - logger - .withContext() - .info("Submitting remittance transaction", { remittanceId: id }); + // Update status to processing before submission + await remittanceService.updateRemittanceStatus(id, 'processing'); - try { - const remittance = await remittanceService.getRemittance(id); + // Submit signed XDR to Stellar and poll for confirmation + const stellarResult = await sorobanService.submitSignedTx(signedXdr); - if (remittance.senderId !== senderAddress) { - throw AppError.forbidden("You do not have access to this remittance"); - } + // Persist completed status with transaction hash + const completed = await remittanceService.updateRemittanceStatus( + id, + 'completed', + stellarResult.txHash, + ); - if (remittance.status !== "pending") { - throw AppError.badRequest("Remittance has already been submitted"); - } + logger.withContext().info('Remittance transaction confirmed', { + remittanceId: id, + txHash: stellarResult.txHash, + status: stellarResult.status, + }); - // Update status to processing before submission - await remittanceService.updateRemittanceStatus(id, "processing"); + // Notify sender of successful submission + await notificationService.createNotification({ + userId: senderAddress, + type: 'repayment_confirmed', + title: 'Remittance Sent', + message: `Your remittance of ${remittance.amount} ${remittance.fromCurrency} was submitted successfully. Transaction: ${stellarResult.txHash}`, + actionUrl: `/remittances/${remittance.id}`, + }); - // Submit signed XDR to Stellar and poll for confirmation - const stellarResult = await sorobanService.submitSignedTx(signedXdr); + res.json({ + success: true, + data: { + id, + status: completed.status, + txHash: stellarResult.txHash, + message: 'Transaction confirmed on Stellar network', + }, + }); + } catch (error) { + logger.withContext().error('Error submitting remittance transaction:', error); - // Persist completed status with transaction hash - const completed = await remittanceService.updateRemittanceStatus( + if (id) { + await remittanceService.updateRemittanceStatus( id, - "completed", - stellarResult.txHash, + 'failed', + undefined, + error instanceof Error ? error.message : 'Unknown error', ); - - logger.withContext().info("Remittance transaction confirmed", { - remittanceId: id, - txHash: stellarResult.txHash, - status: stellarResult.status, - }); - - // Notify sender of successful submission - await notificationService.createNotification({ - userId: senderAddress, - type: "repayment_confirmed", - title: "Remittance Sent", - message: `Your remittance of ${remittance.amount} ${remittance.fromCurrency} was submitted successfully. Transaction: ${stellarResult.txHash}`, - actionUrl: `/remittances/${remittance.id}`, - }); - - res.json({ - success: true, - data: { - id, - status: completed.status, - txHash: stellarResult.txHash, - message: "Transaction confirmed on Stellar network", - }, - }); - } catch (error) { - logger - .withContext() - .error("Error submitting remittance transaction:", error); - - if (id) { - await remittanceService.updateRemittanceStatus( - id, - "failed", - undefined, - error instanceof Error ? error.message : "Unknown error", - ); - } - - throw error; } - }, -); + + throw error; + } +}); diff --git a/backend/src/controllers/scoreController.ts b/backend/src/controllers/scoreController.ts index 5366f21c..95ed0ff4 100644 --- a/backend/src/controllers/scoreController.ts +++ b/backend/src/controllers/scoreController.ts @@ -1,21 +1,21 @@ -import type { Request, Response } from "express"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { query } from "../db/connection.js"; -import { cacheService } from "../services/cacheService.js"; -import { sorobanService } from "../services/sorobanService.js"; +import type { Request, Response } from 'express'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { query } from '../db/connection.js'; +import { cacheService } from '../services/cacheService.js'; +import { sorobanService } from '../services/sorobanService.js'; // --------------------------------------------------------------------------- // Score computation helpers // --------------------------------------------------------------------------- /** Credit bands matching typical lending tiers */ -type CreditBand = "Excellent" | "Good" | "Fair" | "Poor"; +type CreditBand = 'Excellent' | 'Good' | 'Fair' | 'Poor'; function getCreditBand(score: number): CreditBand { - if (score >= 750) return "Excellent"; - if (score >= 670) return "Good"; - if (score >= 580) return "Fair"; - return "Poor"; + if (score >= 750) return 'Excellent'; + if (score >= 670) return 'Good'; + if (score >= 580) return 'Fair'; + return 'Poor'; } // --------------------------------------------------------------------------- @@ -53,18 +53,15 @@ export const getScore = asyncHandler(async (req: Request, res: Response) => { score: cachedScoreParams.score, band: cachedScoreParams.band, factors: { - repaymentHistory: "On-time payments increase score by 15 pts each", - latePaymentPenalty: "Late payments decrease score by 30 pts each", - range: "500 (Poor) – 850 (Excellent)", + repaymentHistory: 'On-time payments increase score by 15 pts each', + latePaymentPenalty: 'Late payments decrease score by 30 pts each', + range: '500 (Poor) – 850 (Excellent)', }, }); return; } - const result = await query( - "SELECT current_score FROM scores WHERE user_id = $1", - [userId], - ); + const result = await query('SELECT current_score FROM scores WHERE user_id = $1', [userId]); const score = result.rows.length > 0 ? result.rows[0].current_score : 500; const band = getCreditBand(score); @@ -77,9 +74,9 @@ export const getScore = asyncHandler(async (req: Request, res: Response) => { score, band, factors: { - repaymentHistory: "On-time payments increase score by 15 pts each", - latePaymentPenalty: "Late payments decrease score by 30 pts each", - range: "500 (Poor) – 850 (Excellent)", + repaymentHistory: 'On-time payments increase score by 15 pts each', + latePaymentPenalty: 'Late payments decrease score by 30 pts each', + range: '500 (Poor) – 850 (Excellent)', }, }); }); @@ -101,12 +98,8 @@ export const updateScore = asyncHandler(async (req: Request, res: Response) => { }; // Get old score first for the response - const oldResult = await query( - "SELECT current_score FROM scores WHERE user_id = $1", - [userId], - ); - const oldScore = - oldResult.rows.length > 0 ? oldResult.rows[0].current_score : 500; + const oldResult = await query('SELECT current_score FROM scores WHERE user_id = $1', [userId]); + const oldScore = oldResult.rows.length > 0 ? oldResult.rows[0].current_score : 500; const delta = onTime ? ON_TIME_DELTA : LATE_DELTA; @@ -157,20 +150,19 @@ export const updateScore = asyncHandler(async (req: Request, res: Response) => { * * This reduces 6+ separate queries to 1-2 efficient round-trips. */ -export const getScoreBreakdown = asyncHandler( - async (req: Request, res: Response) => { - const { userId } = req.params as { userId: string }; - - const cacheKey = `score:breakdown:${userId}`; - const cached = await cacheService.get>(cacheKey); - if (cached) { - res.json({ success: true, ...cached }); - return; - } +export const getScoreBreakdown = asyncHandler(async (req: Request, res: Response) => { + const { userId } = req.params as { userId: string }; + + const cacheKey = `score:breakdown:${userId}`; + const cached = await cacheService.get>(cacheKey); + if (cached) { + res.json({ success: true, ...cached }); + return; + } - // Single unified query that computes all breakdown metrics - const breakdownResult = await query( - `WITH + // Single unified query that computes all breakdown metrics + const breakdownResult = await query( + `WITH -- Current score from scores table current_score_cte AS ( SELECT COALESCE(current_score, 500) AS current_score @@ -263,88 +255,87 @@ export const getScoreBreakdown = asyncHandler( late_count, avg_repayment_ledgers FROM breakdown_summary`, - [userId], - ); - - const breakdown = breakdownResult.rows[0] || {}; - const score = parseInt(breakdown.current_score || "500", 10); - const band = getCreditBand(score); - const totalLoans = parseInt(breakdown.total_loans || "0", 10); - const repaidOnTime = parseInt(breakdown.on_time_count || "0", 10); - const repaidLate = parseInt(breakdown.late_count || "0", 10); - const defaultedCount = parseInt(breakdown.defaulted_count || "0", 10); - const totalRepaid = parseFloat(breakdown.total_repaid || "0"); - - // Convert average ledgers to days (1 ledger ≈ 5 seconds) - const avgLedgers = parseFloat(breakdown.avg_repayment_ledgers || "0"); - const avgDays = Math.round((avgLedgers * 5) / 86400); - const averageRepaymentTime = avgLedgers > 0 ? `${avgDays} days` : "N/A"; - - // Fetch detailed history for streak calculation (separate query is minimal overhead) - const historyResult = await query( - `SELECT + [userId], + ); + + const breakdown = breakdownResult.rows[0] || {}; + const score = parseInt(breakdown.current_score || '500', 10); + const band = getCreditBand(score); + const totalLoans = parseInt(breakdown.total_loans || '0', 10); + const repaidOnTime = parseInt(breakdown.on_time_count || '0', 10); + const repaidLate = parseInt(breakdown.late_count || '0', 10); + const defaultedCount = parseInt(breakdown.defaulted_count || '0', 10); + const totalRepaid = parseFloat(breakdown.total_repaid || '0'); + + // Convert average ledgers to days (1 ledger ≈ 5 seconds) + const avgLedgers = parseFloat(breakdown.avg_repayment_ledgers || '0'); + const avgDays = Math.round((avgLedgers * 5) / 86400); + const averageRepaymentTime = avgLedgers > 0 ? `${avgDays} days` : 'N/A'; + + // Fetch detailed history for streak calculation (separate query is minimal overhead) + const historyResult = await query( + `SELECT event_type, ledger_closed_at FROM contract_events WHERE address = $1 AND event_type IN ('LoanRepaid', 'LoanDefaulted') ORDER BY ledger_closed_at ASC`, - [userId], - ); - - // Build score history by replaying deltas from base 500 - let runningScore = 500; - const history = historyResult.rows.map((row: Record) => { - if (row.event_type === "LoanRepaid") { - runningScore = Math.min(850, runningScore + ON_TIME_DELTA); - } else if (row.event_type === "LoanDefaulted") { - runningScore = Math.max(300, runningScore - 50); - } - return { - date: row.ledger_closed_at - ? new Date(row.ledger_closed_at as string).toISOString().split("T")[0] - : null, - score: runningScore, - event: row.event_type, - }; - }); + [userId], + ); - // Calculate streaks from history - let longestStreak = 0; - let currentStreak = 0; - let tempStreak = 0; - - for (const histItem of history) { - if (histItem.event === "LoanRepaid") { - tempStreak++; - longestStreak = Math.max(longestStreak, tempStreak); - } else { - tempStreak = 0; - } + // Build score history by replaying deltas from base 500 + let runningScore = 500; + const history = historyResult.rows.map((row: Record) => { + if (row.event_type === 'LoanRepaid') { + runningScore = Math.min(850, runningScore + ON_TIME_DELTA); + } else if (row.event_type === 'LoanDefaulted') { + runningScore = Math.max(300, runningScore - 50); } - currentStreak = tempStreak; - - const responseData = { - userId, - score, - band, - breakdown: { - totalLoans, - repaidOnTime, - repaidLate, - defaulted: defaultedCount, - totalRepaid, - averageRepaymentTime, - longestStreak, - currentStreak, - }, - history, + return { + date: row.ledger_closed_at + ? new Date(row.ledger_closed_at as string).toISOString().split('T')[0] + : null, + score: runningScore, + event: row.event_type, }; + }); - await cacheService.set(cacheKey, responseData, 300); + // Calculate streaks from history + let longestStreak = 0; + let currentStreak = 0; + let tempStreak = 0; + + for (const histItem of history) { + if (histItem.event === 'LoanRepaid') { + tempStreak++; + longestStreak = Math.max(longestStreak, tempStreak); + } else { + tempStreak = 0; + } + } + currentStreak = tempStreak; - res.json({ success: true, ...responseData }); - }, -); + const responseData = { + userId, + score, + band, + breakdown: { + totalLoans, + repaidOnTime, + repaidLate, + defaulted: defaultedCount, + totalRepaid, + averageRepaymentTime, + longestStreak, + currentStreak, + }, + history, + }; + + await cacheService.set(cacheKey, responseData, 300); + + res.json({ success: true, ...responseData }); +}); /** * GET /api/score/:walletAddress/history @@ -352,29 +343,27 @@ export const getScoreBreakdown = asyncHandler( * Reads score history from the RemittanceNFT contract and returns a full timeline. * Cached for 60 seconds to avoid excessive Soroban RPC reads. */ -export const getOnChainScoreHistory = asyncHandler( - async (req: Request, res: Response) => { - const { walletAddress } = req.params as { walletAddress: string }; - - const cacheKey = `score:history:onchain:${walletAddress}`; - const cached = await cacheService.get<{ - walletAddress: string; - history: Array<{ score: number; timestamp: number; reason: string }>; - }>(cacheKey); - - if (cached) { - res.json({ success: true, ...cached }); - return; - } +export const getOnChainScoreHistory = asyncHandler(async (req: Request, res: Response) => { + const { walletAddress } = req.params as { walletAddress: string }; + + const cacheKey = `score:history:onchain:${walletAddress}`; + const cached = await cacheService.get<{ + walletAddress: string; + history: Array<{ score: number; timestamp: number; reason: string }>; + }>(cacheKey); - const history = await sorobanService.getOnChainScoreHistory(walletAddress); + if (cached) { + res.json({ success: true, ...cached }); + return; + } + + const history = await sorobanService.getOnChainScoreHistory(walletAddress); - const response = { walletAddress, history }; - await cacheService.set(cacheKey, response, 60); + const response = { walletAddress, history }; + await cacheService.set(cacheKey, response, 60); - res.json({ success: true, ...response }); - }, -); + res.json({ success: true, ...response }); +}); /** * GET /api/score/:walletAddress/nft @@ -382,23 +371,21 @@ export const getOnChainScoreHistory = asyncHandler( * Reads the user's RemittanceNFT metadata and adjacent counters from the * RemittanceNFT contract. Returns `nft: null` when no NFT exists yet. */ -export const getRemittanceNft = asyncHandler( - async (req: Request, res: Response) => { - const { walletAddress } = req.params as { walletAddress: string }; - - const cacheKey = `score:nft:${walletAddress}`; - const cached = await cacheService.get>(cacheKey); - if (cached) { - res.json({ success: true, walletAddress, nft: cached }); - return; - } +export const getRemittanceNft = asyncHandler(async (req: Request, res: Response) => { + const { walletAddress } = req.params as { walletAddress: string }; - const nft = await sorobanService.getRemittanceNftMetadata(walletAddress); + const cacheKey = `score:nft:${walletAddress}`; + const cached = await cacheService.get>(cacheKey); + if (cached) { + res.json({ success: true, walletAddress, nft: cached }); + return; + } - if (nft) { - await cacheService.set(cacheKey, nft, 60); - } + const nft = await sorobanService.getRemittanceNftMetadata(walletAddress); - res.json({ success: true, walletAddress, nft }); - }, -); + if (nft) { + await cacheService.set(cacheKey, nft, 60); + } + + res.json({ success: true, walletAddress, nft }); +}); diff --git a/backend/src/controllers/simulationController.ts b/backend/src/controllers/simulationController.ts index 4c443136..3d1bd398 100644 --- a/backend/src/controllers/simulationController.ts +++ b/backend/src/controllers/simulationController.ts @@ -1,104 +1,92 @@ -import type { Request, Response } from "express"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { query } from "../db/connection.js"; +import type { Request, Response } from 'express'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { query } from '../db/connection.js'; -export const getRemittanceHistory = asyncHandler( - async (req: Request, res: Response) => { - const { userId } = req.params; +export const getRemittanceHistory = asyncHandler(async (req: Request, res: Response) => { + const { userId } = req.params; - // 1. Fetch current score from database - const scoreResult = await query( - "SELECT current_score FROM scores WHERE user_id = $1", - [userId], - ); - const score = scoreResult.rows[0]?.current_score ?? 500; + // 1. Fetch current score from database + const scoreResult = await query('SELECT current_score FROM scores WHERE user_id = $1', [userId]); + const score = scoreResult.rows[0]?.current_score ?? 500; - // 2. Fetch all repayment and default events for history calculation - const eventsResult = await query( - `SELECT event_type, amount, ledger_closed_at + // 2. Fetch all repayment and default events for history calculation + const eventsResult = await query( + `SELECT event_type, amount, ledger_closed_at FROM contract_events WHERE address = $1 AND event_type IN ('LoanRepaid', 'LoanDefaulted') ORDER BY ledger_closed_at ASC`, - [userId], - ); + [userId], + ); - const events = eventsResult.rows; + const events = eventsResult.rows; - // 3. Group by month for display - const historyMap = new Map< - string, - { month: string; amount: number; status: string } - >(); + // 3. Group by month for display + const historyMap = new Map(); - for (const e of events) { - const date = new Date(e.ledger_closed_at); - const monthYear = date.toLocaleString("en-US", { - month: "long", - year: "numeric", - }); - const month = date.toLocaleString("en-US", { month: "long" }); + for (const e of events) { + const date = new Date(e.ledger_closed_at); + const monthYear = date.toLocaleString('en-US', { + month: 'long', + year: 'numeric', + }); + const month = date.toLocaleString('en-US', { month: 'long' }); - const existing = historyMap.get(monthYear); - if (e.event_type === "LoanRepaid") { - if (existing) { - existing.amount += parseFloat(e.amount || "0") / 10000000; // Assuming 7 decimals - } else { - historyMap.set(monthYear, { - month, - amount: parseFloat(e.amount || "0") / 10000000, - status: "Completed", - }); - } - } else if (e.event_type === "LoanDefaulted") { - if (existing) { - existing.status = "Defaulted"; - } else { - historyMap.set(monthYear, { month, amount: 0, status: "Defaulted" }); - } + const existing = historyMap.get(monthYear); + if (e.event_type === 'LoanRepaid') { + if (existing) { + existing.amount += parseFloat(e.amount || '0') / 10000000; // Assuming 7 decimals + } else { + historyMap.set(monthYear, { + month, + amount: parseFloat(e.amount || '0') / 10000000, + status: 'Completed', + }); + } + } else if (e.event_type === 'LoanDefaulted') { + if (existing) { + existing.status = 'Defaulted'; + } else { + historyMap.set(monthYear, { month, amount: 0, status: 'Defaulted' }); } } + } - const history = Array.from(historyMap.values()).slice(-6); + const history = Array.from(historyMap.values()).slice(-6); - // 4. Calculate streak (consecutive "Completed" months from history) - let streak = 0; - const historyReverse = Array.from(historyMap.values()).reverse(); - for (const h of historyReverse) { - if (h.status === "Completed") { - streak++; - } else if (h.status === "Defaulted") { - break; - } + // 4. Calculate streak (consecutive "Completed" months from history) + let streak = 0; + const historyReverse = Array.from(historyMap.values()).reverse(); + for (const h of historyReverse) { + if (h.status === 'Completed') { + streak++; + } else if (h.status === 'Defaulted') { + break; } + } - res.json({ - userId, - score, - streak, - history, - }); - }, -); + res.json({ + userId, + score, + streak, + history, + }); +}); export const simulatePayment = asyncHandler( async (req: Request, res: Response) => { const { amount } = req.body; const userId = req.user!.publicKey; - // Fetch current score - const scoreResult = await query( - "SELECT current_score FROM scores WHERE user_id = $1", - [userId], - ); - const currentScore = scoreResult.rows[0]?.current_score ?? 500; + // Fetch current score + const scoreResult = await query('SELECT current_score FROM scores WHERE user_id = $1', [userId]); + const currentScore = scoreResult.rows[0]?.current_score ?? 500; - // Calculation matches eventIndexer.ts: +15 for each repayment - const newScore = Math.min(850, currentScore + 15); + // Calculation matches eventIndexer.ts: +15 for each repayment + const newScore = Math.min(850, currentScore + 15); - res.json({ - success: true, - message: `A payment of ${amount} would increase your estimated credit score from ${currentScore} to ${newScore}.`, - newScore, - }); - }, -); + res.json({ + success: true, + message: `A payment of ${amount} would increase your estimated credit score from ${currentScore} to ${newScore}.`, + newScore, + }); +}); diff --git a/backend/src/controllers/transactionController.ts b/backend/src/controllers/transactionController.ts index 25c9c629..1a1dea5f 100644 --- a/backend/src/controllers/transactionController.ts +++ b/backend/src/controllers/transactionController.ts @@ -1,39 +1,38 @@ -import type { Request, Response } from "express"; -import { query } from "../db/connection.js"; -import { AppError } from "../errors/AppError.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; +import type { Request, Response } from 'express'; +import { query } from '../db/connection.js'; +import { AppError } from '../errors/AppError.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; const DEFAULT_LIMIT = 20; const MAX_LIMIT = 50; function parseLimit(value: unknown): number { - if (typeof value !== "string") return DEFAULT_LIMIT; + if (typeof value !== 'string') return DEFAULT_LIMIT; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT; return Math.min(parsed, MAX_LIMIT); } function parseCursor(value: unknown): number | null { - if (typeof value !== "string" || value.trim().length === 0) return null; + if (typeof value !== 'string' || value.trim().length === 0) return null; const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } -export const listMyTransactions = asyncHandler( - async (req: Request, res: Response) => { - const publicKey = req.user?.publicKey; - if (!publicKey) { - throw AppError.unauthorized("Authentication required"); - } +export const listMyTransactions = asyncHandler(async (req: Request, res: Response) => { + const publicKey = req.user?.publicKey; + if (!publicKey) { + throw AppError.unauthorized('Authentication required'); + } - const limit = parseLimit(req.query.limit); - const cursor = parseCursor(req.query.cursor); - const params: Array = [publicKey, limit + 1]; - const cursorClause = cursor ? "AND id < $3" : ""; - if (cursor) params.push(cursor); + const limit = parseLimit(req.query.limit); + const cursor = parseCursor(req.query.cursor); + const params: Array = [publicKey, limit + 1]; + const cursorClause = cursor ? 'AND id < $3' : ''; + if (cursor) params.push(cursor); - const result = await query( - `SELECT + const result = await query( + `SELECT id, tx_hash, status, @@ -46,31 +45,30 @@ export const listMyTransactions = asyncHandler( ${cursorClause} ORDER BY id DESC LIMIT $2`, - params, - ); + params, + ); - const rows = result.rows.slice(0, limit); - const hasNext = result.rows.length > limit; - const nextCursor = hasNext ? String(rows[rows.length - 1]?.id) : null; + const rows = result.rows.slice(0, limit); + const hasNext = result.rows.length > limit; + const nextCursor = hasNext ? String(rows[rows.length - 1]?.id) : null; - res.json({ - success: true, - data: rows.map((row) => ({ - id: row.id, - txHash: row.tx_hash, - status: row.status, - submittedAt: row.submitted_at, - submittedBy: row.submitted_by, - transactionType: row.transaction_type, - resultXdr: row.result_xdr, - })), - page_info: { - limit, - count: rows.length, - next_cursor: nextCursor, - has_previous: cursor !== null, - has_next: hasNext, - }, - }); - }, -); + res.json({ + success: true, + data: rows.map((row) => ({ + id: row.id, + txHash: row.tx_hash, + status: row.status, + submittedAt: row.submitted_at, + submittedBy: row.submitted_by, + transactionType: row.transaction_type, + resultXdr: row.result_xdr, + })), + page_info: { + limit, + count: rows.length, + next_cursor: nextCursor, + has_previous: cursor !== null, + has_next: hasNext, + }, + }); +}); diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 9a742072..027c9cdf 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -1,8 +1,8 @@ -import type { Request, Response } from "express"; -import { query } from "../db/connection.js"; -import { AppError } from "../errors/AppError.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import type { UpdateUserProfileInput } from "../schemas/userSchemas.js"; +import type { Request, Response } from 'express'; +import { query } from '../db/connection.js'; +import { AppError } from '../errors/AppError.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import type { UpdateUserProfileInput } from '../schemas/userSchemas.js'; interface UserProfileRow { id: number; @@ -18,13 +18,11 @@ interface UserProfileRow { } function metadataFrom(row: UserProfileRow): Record { - return row.metadata && typeof row.metadata === "object" ? row.metadata : {}; + return row.metadata && typeof row.metadata === 'object' ? row.metadata : {}; } function toIsoString(value: Date | string): string { - return value instanceof Date - ? value.toISOString() - : new Date(value).toISOString(); + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } function serializeProfile(row: UserProfileRow) { @@ -32,18 +30,16 @@ function serializeProfile(row: UserProfileRow) { return { id: String(row.id), - email: row.email ?? "", + email: row.email ?? '', walletAddress: row.public_key, - kycVerified: Boolean( - metadata.kycVerified ?? metadata.kyc_verified ?? false, - ), - displayName: row.display_name ?? "", - phone: row.phone ?? "", - locale: typeof metadata.locale === "string" ? metadata.locale : undefined, + kycVerified: Boolean(metadata.kycVerified ?? metadata.kyc_verified ?? false), + displayName: row.display_name ?? '', + phone: row.phone ?? '', + locale: typeof metadata.locale === 'string' ? metadata.locale : undefined, avatarUrl: - typeof metadata.avatarUrl === "string" + typeof metadata.avatarUrl === 'string' ? metadata.avatarUrl - : typeof metadata.avatar_url === "string" + : typeof metadata.avatar_url === 'string' ? metadata.avatar_url : undefined, emailEnabled: Boolean(row.email_enabled), @@ -70,94 +66,90 @@ async function getOrCreateProfile(publicKey: string): Promise { const profile = result.rows[0] as UserProfileRow | undefined; if (!profile) { - throw AppError.internal("Unable to load user profile"); + throw AppError.internal('Unable to load user profile'); } return profile; } -export const getUserProfile = asyncHandler( - async (req: Request, res: Response) => { - const publicKey = req.user?.publicKey; - if (!publicKey) { - throw AppError.unauthorized("Authentication required"); - } +export const getUserProfile = asyncHandler(async (req: Request, res: Response) => { + const publicKey = req.user?.publicKey; + if (!publicKey) { + throw AppError.unauthorized('Authentication required'); + } - const profile = await getOrCreateProfile(publicKey); - res.json(serializeProfile(profile)); - }, -); + const profile = await getOrCreateProfile(publicKey); + res.json(serializeProfile(profile)); +}); -export const updateUserProfile = asyncHandler( - async (req: Request, res: Response) => { - const publicKey = req.user?.publicKey; - if (!publicKey) { - throw AppError.unauthorized("Authentication required"); - } +export const updateUserProfile = asyncHandler(async (req: Request, res: Response) => { + const publicKey = req.user?.publicKey; + if (!publicKey) { + throw AppError.unauthorized('Authentication required'); + } - const input = req.body as UpdateUserProfileInput; - const current = await getOrCreateProfile(publicKey); - const updates: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; + const input = req.body as UpdateUserProfileInput; + const current = await getOrCreateProfile(publicKey); + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; - if (input.displayName !== undefined) { - updates.push(`display_name = $${paramIndex++}`); - values.push(input.displayName); - } + if (input.displayName !== undefined) { + updates.push(`display_name = $${paramIndex++}`); + values.push(input.displayName); + } - if (input.email !== undefined) { - updates.push(`email = $${paramIndex++}`); - values.push(input.email); - } + if (input.email !== undefined) { + updates.push(`email = $${paramIndex++}`); + values.push(input.email); + } - if (input.phone !== undefined) { - updates.push(`phone = $${paramIndex++}`); - values.push(input.phone); - } + if (input.phone !== undefined) { + updates.push(`phone = $${paramIndex++}`); + values.push(input.phone); + } - if (input.locale !== undefined || input.avatarUrl !== undefined) { - const metadata = { ...metadataFrom(current) }; - - if (input.locale === null) { - delete metadata.locale; - } else if (input.locale !== undefined) { - metadata.locale = input.locale; - } - - if (input.avatarUrl === null) { - delete metadata.avatarUrl; - delete metadata.avatar_url; - } else if (input.avatarUrl !== undefined) { - metadata.avatarUrl = input.avatarUrl; - delete metadata.avatar_url; - } - - updates.push(`metadata = $${paramIndex++}::jsonb`); - values.push(JSON.stringify(metadata)); + if (input.locale !== undefined || input.avatarUrl !== undefined) { + const metadata = { ...metadataFrom(current) }; + + if (input.locale === null) { + delete metadata.locale; + } else if (input.locale !== undefined) { + metadata.locale = input.locale; } - if (updates.length === 0) { - res.json(serializeProfile(current)); - return; + if (input.avatarUrl === null) { + delete metadata.avatarUrl; + delete metadata.avatar_url; + } else if (input.avatarUrl !== undefined) { + metadata.avatarUrl = input.avatarUrl; + delete metadata.avatar_url; } - updates.push("updated_at = CURRENT_TIMESTAMP"); - values.push(publicKey); + updates.push(`metadata = $${paramIndex++}::jsonb`); + values.push(JSON.stringify(metadata)); + } + + if (updates.length === 0) { + res.json(serializeProfile(current)); + return; + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + values.push(publicKey); - const result = await query( - `UPDATE user_profiles - SET ${updates.join(", ")} + const result = await query( + `UPDATE user_profiles + SET ${updates.join(', ')} WHERE public_key = $${paramIndex} RETURNING *`, - values, - ); + values, + ); - const updated = result.rows[0] as UserProfileRow | undefined; - if (!updated) { - throw AppError.notFound("User profile not found"); - } + const updated = result.rows[0] as UserProfileRow | undefined; + if (!updated) { + throw AppError.notFound('User profile not found'); + } - res.json(serializeProfile(updated)); - }, -); + res.json(serializeProfile(updated)); +}); diff --git a/backend/src/cron/__tests__/scoreDecayJob.test.ts b/backend/src/cron/__tests__/scoreDecayJob.test.ts index 68005b08..2735907b 100644 --- a/backend/src/cron/__tests__/scoreDecayJob.test.ts +++ b/backend/src/cron/__tests__/scoreDecayJob.test.ts @@ -1,4 +1,4 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; // Explicitly type the mocks to match the real function signatures type Borrower = { @@ -6,36 +6,33 @@ type Borrower = { score: number; last_repayment: string | null; }; -const mockGetInactiveBorrowers: jest.MockedFunction<() => Promise> = - jest.fn(); -const mockApplyScoreDecay: jest.MockedFunction< - (b: Borrower) => Promise -> = jest.fn(); +const mockGetInactiveBorrowers: jest.MockedFunction<() => Promise> = jest.fn(); +const mockApplyScoreDecay: jest.MockedFunction<(b: Borrower) => Promise> = jest.fn(); -jest.unstable_mockModule("../../services/scoreDecayService.js", () => ({ +jest.unstable_mockModule('../../services/scoreDecayService.js', () => ({ getInactiveBorrowers: mockGetInactiveBorrowers, applyScoreDecay: mockApplyScoreDecay, })); -describe("scoreDecayJob", () => { +describe('scoreDecayJob', () => { afterEach(() => { jest.clearAllMocks(); }); - it("should apply score decay to all inactive borrowers", async () => { + it('should apply score decay to all inactive borrowers', async () => { const borrowers = [ { - borrower: "user1", + borrower: 'user1', score: 700, - last_repayment: "2024-01-01T00:00:00.000Z", + last_repayment: '2024-01-01T00:00:00.000Z', }, - { borrower: "user2", score: 650, last_repayment: null }, + { borrower: 'user2', score: 650, last_repayment: null }, ]; mockGetInactiveBorrowers.mockResolvedValue(borrowers); mockApplyScoreDecay.mockResolvedValue(0); // Import the job after mocks - const { default: runScoreDecayJob } = await import("../scoreDecayJob.js"); + const { default: runScoreDecayJob } = await import('../scoreDecayJob.js'); await runScoreDecayJob(); expect(mockGetInactiveBorrowers).toHaveBeenCalled(); @@ -44,9 +41,9 @@ describe("scoreDecayJob", () => { expect(mockApplyScoreDecay).toHaveBeenCalledWith(borrowers[1]); }); - it("should handle errors gracefully", async () => { - mockGetInactiveBorrowers.mockRejectedValue(new Error("DB error")); - const { default: runScoreDecayJob } = await import("../scoreDecayJob.js"); + it('should handle errors gracefully', async () => { + mockGetInactiveBorrowers.mockRejectedValue(new Error('DB error')); + const { default: runScoreDecayJob } = await import('../scoreDecayJob.js'); await expect(runScoreDecayJob()).resolves.not.toThrow(); }); }); diff --git a/backend/src/cron/loanCheckCron.ts b/backend/src/cron/loanCheckCron.ts index 36438950..e886b4b6 100644 --- a/backend/src/cron/loanCheckCron.ts +++ b/backend/src/cron/loanCheckCron.ts @@ -1,15 +1,15 @@ -import cron from "node-cron"; -import { query } from "../db/connection.js"; -import { notificationService } from "../services/notificationService.js"; -import logger from "../utils/logger.js"; +import cron from 'node-cron'; +import { query } from '../db/connection.js'; +import { notificationService } from '../services/notificationService.js'; +import logger from '../utils/logger.js'; /** * Checks for loans that are due soon (e.g., within 24 hours) and notifies borrowers. * Runs every hour at the top of the hour. */ export function startLoanDueCheckCron() { - cron.schedule("0 * * * *", async () => { - logger.info("Running loan due check cron..."); + cron.schedule('0 * * * *', async () => { + logger.info('Running loan due check cron...'); try { // Find loans where a repayment is due in the next 24 hours @@ -28,18 +28,16 @@ export function startLoanDueCheckCron() { for (const loan of result.rows) { await notificationService.createNotification({ userId: loan.address, - type: "repayment_due", - title: "Repayment Due Soon", + type: 'repayment_due', + title: 'Repayment Due Soon', message: `Your repayment for loan #${loan.loan_id} of ${loan.amount} is due.`, loanId: loan.loan_id, }); } - logger.info( - `Loan due check completed. Notified ${result.rows.length} borrowers.`, - ); + logger.info(`Loan due check completed. Notified ${result.rows.length} borrowers.`); } catch (error) { - logger.error("Error in loan due check cron", { error }); + logger.error('Error in loan due check cron', { error }); } }); } diff --git a/backend/src/cron/scoreDecayJob.ts b/backend/src/cron/scoreDecayJob.ts index 62c23972..a071aa9c 100644 --- a/backend/src/cron/scoreDecayJob.ts +++ b/backend/src/cron/scoreDecayJob.ts @@ -1,16 +1,13 @@ // Cron job to apply score decay to inactive borrowers // Run this script periodically (e.g., daily) via a scheduler or as part of backend startup -import { - getInactiveBorrowers, - applyScoreDecay, -} from "../services/scoreDecayService.js"; -import { jobMetricsService } from "../services/jobMetricsService.js"; -import logger from "../utils/logger.js"; +import { getInactiveBorrowers, applyScoreDecay } from '../services/scoreDecayService.js'; +import { jobMetricsService } from '../services/jobMetricsService.js'; +import logger from '../utils/logger.js'; async function runScoreDecayJob() { const startTime = Date.now(); - const jobName = "scoreDecayJob"; + const jobName = 'scoreDecayJob'; try { const borrowers = await getInactiveBorrowers(); @@ -19,14 +16,14 @@ async function runScoreDecayJob() { } const durationMs = Date.now() - startTime; jobMetricsService.recordSuccess(jobName, durationMs); - logger.info("Score decay job completed", { + logger.info('Score decay job completed', { borrowersProcessed: borrowers.length, durationMs, }); } catch (err) { const durationMs = Date.now() - startTime; jobMetricsService.recordFailure(jobName, err as Error | string, durationMs); - logger.error("Score decay job failed:", { err, durationMs }); + logger.error('Score decay job failed:', { err, durationMs }); } } diff --git a/backend/src/db/connection.ts b/backend/src/db/connection.ts index a7421be2..920dde45 100644 --- a/backend/src/db/connection.ts +++ b/backend/src/db/connection.ts @@ -1,17 +1,13 @@ -import pg, { type PoolClient } from "pg"; -import logger from "../utils/logger.js"; +import pg, { type PoolClient } from 'pg'; +import logger from '../utils/logger.js'; export type { PoolClient }; const { Pool } = pg; // Parse pool configuration from environment -const maxPoolSize = process.env.DB_POOL_MAX - ? parseInt(process.env.DB_POOL_MAX, 10) - : 10; -const minPoolSize = process.env.DB_POOL_MIN - ? parseInt(process.env.DB_POOL_MIN, 10) - : 2; +const maxPoolSize = process.env.DB_POOL_MAX ? parseInt(process.env.DB_POOL_MAX, 10) : 10; +const minPoolSize = process.env.DB_POOL_MIN ? parseInt(process.env.DB_POOL_MIN, 10) : 2; const idleTimeoutMillis = process.env.DB_IDLE_TIMEOUT_MS ? parseInt(process.env.DB_IDLE_TIMEOUT_MS, 10) : 30000; @@ -27,7 +23,7 @@ let isShuttingDown = false; // Periodic pool health metrics logging const metricsInterval = setInterval(() => { - logger.info("DB Pool Metrics", { + logger.info('DB Pool Metrics', { total: pool.totalCount, idle: pool.idleCount, active: pool.totalCount - pool.idleCount, @@ -39,21 +35,21 @@ const metricsInterval = setInterval(() => { metricsInterval.unref(); // Log idle client errors -pool.on("error", (err: Error) => { - logger.error("Unexpected error on idle client", err); +pool.on('error', (err: Error) => { + logger.error('Unexpected error on idle client', err); }); // Helper for transient failures export const TRANSIENT_ERROR_CODES = new Set([ - "ECONNREFUSED", - "08000", - "08003", - "08006", - "57P01", // admin_shutdown - "57P02", // crash_shutdown - "57P03", // cannot_connect_now - "40001", // serialization_failure - "40P01", // deadlock_detected + 'ECONNREFUSED', + '08000', + '08003', + '08006', + '57P01', // admin_shutdown + '57P02', // crash_shutdown + '57P03', // cannot_connect_now + '40001', // serialization_failure + '40P01', // deadlock_detected ]); const MAX_RETRIES = 3; @@ -100,20 +96,18 @@ export async function withTransaction( while (true) { const client = await getClient(); try { - await client.query("BEGIN"); + await client.query('BEGIN'); const result = await fn(client); - await client.query("COMMIT"); + await client.query('COMMIT'); return result; } catch (error) { try { - await client.query("ROLLBACK"); + await client.query('ROLLBACK'); } catch (rollbackError) { - logger.error("Failed to rollback transaction", { rollbackError }); + logger.error('Failed to rollback transaction', { rollbackError }); } - const isTransient = TRANSIENT_ERROR_CODES.has( - (error as { code: string }).code, - ); + const isTransient = TRANSIENT_ERROR_CODES.has((error as { code: string }).code); if (isTransient && attempt < maxRetries) { const delay = baseDelayMs * 2 ** attempt; attempt++; @@ -134,26 +128,23 @@ export async function withTransaction( const checkExhaustion = () => { if (pool.totalCount >= maxPoolSize && pool.idleCount === 0) { - logger.warn( - "DB Pool Exhaustion Warning: All connections are currently in use.", - { - waiting: pool.waitingCount, - active: pool.totalCount, - }, - ); + logger.warn('DB Pool Exhaustion Warning: All connections are currently in use.', { + waiting: pool.waitingCount, + active: pool.totalCount, + }); } }; export const query = async (text: string, params?: unknown[]) => { if (isShuttingDown) { - throw new Error("Database pool is shutting down"); + throw new Error('Database pool is shutting down'); } checkExhaustion(); return withRetry(async () => { const start = Date.now(); const result = await pool.query(text, params); const duration = Date.now() - start; - logger.debug("Executed query", { + logger.debug('Executed query', { text: text.substring(0, 50), duration, rows: result.rowCount, @@ -164,7 +155,7 @@ export const query = async (text: string, params?: unknown[]) => { export const getClient = async () => { if (isShuttingDown) { - throw new Error("Database pool is shutting down"); + throw new Error('Database pool is shutting down'); } checkExhaustion(); return withRetry(async () => { @@ -178,9 +169,7 @@ const waitForPoolToDrain = async (timeoutMs: number): Promise => { while (pool.totalCount > 0 && pool.totalCount !== pool.idleCount) { if (Date.now() - startedAt >= timeoutMs) { - throw new Error( - `Timed out waiting for pool to drain active clients after ${timeoutMs}ms`, - ); + throw new Error(`Timed out waiting for pool to drain active clients after ${timeoutMs}ms`); } await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/backend/src/db/transaction.ts b/backend/src/db/transaction.ts index b78a021a..57bd8373 100644 --- a/backend/src/db/transaction.ts +++ b/backend/src/db/transaction.ts @@ -1,5 +1,5 @@ -import { getClient } from "./connection.js"; -import logger from "../utils/logger.js"; +import { getClient } from './connection.js'; +import logger from '../utils/logger.js'; /** * Execute a database transaction with automatic rollback on error @@ -7,35 +7,35 @@ import logger from "../utils/logger.js"; * @returns Promise with the result of the operations */ export async function withTransaction( - operations: (client: import("pg").PoolClient) => Promise, + operations: (client: import('pg').PoolClient) => Promise, ): Promise { let client; try { client = await getClient(); } catch (error) { - logger.error("Failed to acquire database client for transaction", { + logger.error('Failed to acquire database client for transaction', { error, }); - throw new Error("Database connection failed"); + throw new Error('Database connection failed'); } if (!client) { - throw new Error("Database client is undefined"); + throw new Error('Database client is undefined'); } try { - await client.query("BEGIN"); - logger.debug("Database transaction started"); + await client.query('BEGIN'); + logger.debug('Database transaction started'); const result = await operations(client); - await client.query("COMMIT"); - logger.debug("Database transaction committed"); + await client.query('COMMIT'); + logger.debug('Database transaction committed'); return result; } catch (error) { - await client.query("ROLLBACK"); - logger.error("Database transaction rolled back due to error:", error); + await client.query('ROLLBACK'); + logger.error('Database transaction rolled back due to error:', error); throw error; } finally { client.release(); @@ -70,10 +70,7 @@ export async function executeTransactionQueries( */ export async function withStellarAndDbTransaction( stellarOperation: () => Promise, - dbOperations: ( - stellarResult: unknown, - client: import("pg").PoolClient, - ) => Promise, + dbOperations: (stellarResult: unknown, client: import('pg').PoolClient) => Promise, ): Promise<{ stellarResult: unknown; dbResult: T }> { return withTransaction(async (client) => { try { @@ -85,14 +82,14 @@ export async function withStellarAndDbTransaction( return { stellarResult, dbResult }; } catch (error) { - logger.error("Operation failed in Stellar+DB transaction:", { - error: error instanceof Error ? error.message : "Unknown error", + logger.error('Operation failed in Stellar+DB transaction:', { + error: error instanceof Error ? error.message : 'Unknown error', // Don't log sensitive Stellar data }); // Log for reconciliation since Stellar transaction might have succeeded // but DB write failed - logger.warn("Stellar transaction might need manual reconciliation", { + logger.warn('Stellar transaction might need manual reconciliation', { timestamp: new Date().toISOString(), }); diff --git a/backend/src/errors/AppError.ts b/backend/src/errors/AppError.ts index b46f7871..f2ef1529 100644 --- a/backend/src/errors/AppError.ts +++ b/backend/src/errors/AppError.ts @@ -1,8 +1,4 @@ -import { - ErrorCode, - ERROR_CODE_REGISTRY, - getDefaultErrorCodeForStatus, -} from "./errorCodes.js"; +import { ErrorCode, ERROR_CODE_REGISTRY, getDefaultErrorCodeForStatus } from './errorCodes.js'; /** * Custom application error class for centralized error handling. @@ -13,7 +9,7 @@ import { */ export class AppError extends Error { public readonly statusCode: number; - public readonly status: "fail" | "error"; + public readonly status: 'fail' | 'error'; public readonly isOperational: boolean; public readonly errorCode: ErrorCode; public readonly field?: string | undefined; @@ -29,7 +25,7 @@ export class AppError extends Error { ) { super(message); this.statusCode = statusCode; - this.status = statusCode >= 400 && statusCode < 500 ? "fail" : "error"; + this.status = statusCode >= 400 && statusCode < 500 ? 'fail' : 'error'; this.isOperational = isOperational; this.errorCode = errorCode ?? getDefaultErrorCodeForStatus(statusCode); this.field = field; @@ -41,82 +37,39 @@ export class AppError extends Error { /* ── Factory Methods ─────────────────────────────────────────── */ - static badRequest( - message = "Bad request", - errorCode?: ErrorCode, - field?: string, - ): AppError { - return new AppError( - message, - 400, - true, - errorCode ?? ErrorCode.INVALID_AMOUNT, - field, - ); + static badRequest(message = 'Bad request', errorCode?: ErrorCode, field?: string): AppError { + return new AppError(message, 400, true, errorCode ?? ErrorCode.INVALID_AMOUNT, field); } - static unauthorized( - message = "Unauthorized", - errorCode?: ErrorCode, - ): AppError { - return new AppError( - message, - 401, - true, - errorCode ?? ErrorCode.UNAUTHORIZED, - ); + static unauthorized(message = 'Unauthorized', errorCode?: ErrorCode): AppError { + return new AppError(message, 401, true, errorCode ?? ErrorCode.UNAUTHORIZED); } - static forbidden(message = "Forbidden", errorCode?: ErrorCode): AppError { + static forbidden(message = 'Forbidden', errorCode?: ErrorCode): AppError { return new AppError(message, 403, true, errorCode ?? ErrorCode.FORBIDDEN); } - static notFound( - message = "Not found", - errorCode?: ErrorCode, - field?: string, - ): AppError { - return new AppError( - message, - 404, - true, - errorCode ?? ErrorCode.NOT_FOUND, - field, - ); + static notFound(message = 'Not found', errorCode?: ErrorCode, field?: string): AppError { + return new AppError(message, 404, true, errorCode ?? ErrorCode.NOT_FOUND, field); } - static conflict(message = "Conflict", errorCode?: ErrorCode): AppError { + static conflict(message = 'Conflict', errorCode?: ErrorCode): AppError { return new AppError(message, 409, true, errorCode ?? ErrorCode.CONFLICT); } - static internal( - message = "Internal server error", - errorCode?: ErrorCode, - ): AppError { - return new AppError( - message, - 500, - false, - errorCode ?? ErrorCode.INTERNAL_ERROR, - ); + static internal(message = 'Internal server error', errorCode?: ErrorCode): AppError { + return new AppError(message, 500, false, errorCode ?? ErrorCode.INTERNAL_ERROR); } /** * Create a validation error with field information. */ static validation( - message = "Validation failed", + message = 'Validation failed', field?: string, details?: Record, ): AppError { - return new AppError( - message, - 400, - true, - ErrorCode.VALIDATION_ERROR, - field, - details, - ); + return new AppError(message, 400, true, ErrorCode.VALIDATION_ERROR, field, details); } /** diff --git a/backend/src/errors/errorCodes.ts b/backend/src/errors/errorCodes.ts index 0476cfe1..b340fc5c 100644 --- a/backend/src/errors/errorCodes.ts +++ b/backend/src/errors/errorCodes.ts @@ -11,49 +11,49 @@ export enum ErrorCode { // Validation Errors (400) - INVALID_AMOUNT = "INVALID_AMOUNT", - INVALID_PUBLIC_KEY = "INVALID_PUBLIC_KEY", - INVALID_SIGNATURE = "INVALID_SIGNATURE", - INVALID_CHALLENGE = "INVALID_CHALLENGE", - MISSING_FIELD = "MISSING_FIELD", - VALIDATION_ERROR = "VALIDATION_ERROR", + INVALID_AMOUNT = 'INVALID_AMOUNT', + INVALID_PUBLIC_KEY = 'INVALID_PUBLIC_KEY', + INVALID_SIGNATURE = 'INVALID_SIGNATURE', + INVALID_CHALLENGE = 'INVALID_CHALLENGE', + MISSING_FIELD = 'MISSING_FIELD', + VALIDATION_ERROR = 'VALIDATION_ERROR', // Authentication Errors (401) - UNAUTHORIZED = "UNAUTHORIZED", - TOKEN_EXPIRED = "TOKEN_EXPIRED", - TOKEN_INVALID = "TOKEN_INVALID", - CHALLENGE_EXPIRED = "CHALLENGE_EXPIRED", + UNAUTHORIZED = 'UNAUTHORIZED', + TOKEN_EXPIRED = 'TOKEN_EXPIRED', + TOKEN_INVALID = 'TOKEN_INVALID', + CHALLENGE_EXPIRED = 'CHALLENGE_EXPIRED', // Authorization Errors (403) - FORBIDDEN = "FORBIDDEN", - ACCESS_DENIED = "ACCESS_DENIED", + FORBIDDEN = 'FORBIDDEN', + ACCESS_DENIED = 'ACCESS_DENIED', // Not Found Errors (404) - NOT_FOUND = "NOT_FOUND", - LOAN_NOT_FOUND = "LOAN_NOT_FOUND", - USER_NOT_FOUND = "USER_NOT_FOUND", - POOL_NOT_FOUND = "POOL_NOT_FOUND", + NOT_FOUND = 'NOT_FOUND', + LOAN_NOT_FOUND = 'LOAN_NOT_FOUND', + USER_NOT_FOUND = 'USER_NOT_FOUND', + POOL_NOT_FOUND = 'POOL_NOT_FOUND', // Conflict Errors (409) - CONFLICT = "CONFLICT", - DUPLICATE_REQUEST = "DUPLICATE_REQUEST", + CONFLICT = 'CONFLICT', + DUPLICATE_REQUEST = 'DUPLICATE_REQUEST', // Rate Limiting (429) - RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', // Server Errors (500) - INTERNAL_ERROR = "INTERNAL_ERROR", - DATABASE_ERROR = "DATABASE_ERROR", - EXTERNAL_SERVICE_ERROR = "EXTERNAL_SERVICE_ERROR", - BLOCKCHAIN_ERROR = "BLOCKCHAIN_ERROR", + INTERNAL_ERROR = 'INTERNAL_ERROR', + DATABASE_ERROR = 'DATABASE_ERROR', + EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR', + BLOCKCHAIN_ERROR = 'BLOCKCHAIN_ERROR', // Specific Business Logic Errors - BORROWER_MISMATCH = "BORROWER_MISMATCH", - INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE", - LOAN_ALREADY_REPAID = "LOAN_ALREADY_REPAID", - LOAN_NOT_ACTIVE = "LOAN_NOT_ACTIVE", - INVALID_LOAN_ID = "INVALID_LOAN_ID", - INVALID_TX_XDR = "INVALID_TX_XDR", + BORROWER_MISMATCH = 'BORROWER_MISMATCH', + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + LOAN_ALREADY_REPAID = 'LOAN_ALREADY_REPAID', + LOAN_NOT_ACTIVE = 'LOAN_NOT_ACTIVE', + INVALID_LOAN_ID = 'INVALID_LOAN_ID', + INVALID_TX_XDR = 'INVALID_TX_XDR', } /** @@ -74,221 +74,220 @@ export const ERROR_CODE_REGISTRY: Record = { // Validation Errors [ErrorCode.INVALID_AMOUNT]: { code: ErrorCode.INVALID_AMOUNT, - message: "Amount must be a positive number", + message: 'Amount must be a positive number', httpStatus: 400, - description: "The provided amount is invalid or not a positive number", - suggestedAction: "Provide a valid positive number for the amount field", + description: 'The provided amount is invalid or not a positive number', + suggestedAction: 'Provide a valid positive number for the amount field', }, [ErrorCode.INVALID_PUBLIC_KEY]: { code: ErrorCode.INVALID_PUBLIC_KEY, - message: "Invalid Stellar public key", + message: 'Invalid Stellar public key', httpStatus: 400, - description: "The provided Stellar public key format is invalid", + description: 'The provided Stellar public key format is invalid', suggestedAction: "Provide a valid Stellar public key (starts with 'G')", }, [ErrorCode.INVALID_SIGNATURE]: { code: ErrorCode.INVALID_SIGNATURE, - message: "Invalid signature", + message: 'Invalid signature', httpStatus: 400, - description: "The provided cryptographic signature is invalid", - suggestedAction: "Sign the challenge message with your wallet and retry", + description: 'The provided cryptographic signature is invalid', + suggestedAction: 'Sign the challenge message with your wallet and retry', }, [ErrorCode.INVALID_CHALLENGE]: { code: ErrorCode.INVALID_CHALLENGE, - message: "Invalid challenge format", + message: 'Invalid challenge format', httpStatus: 400, - description: "The challenge message format is invalid", - suggestedAction: "Request a new challenge and retry", + description: 'The challenge message format is invalid', + suggestedAction: 'Request a new challenge and retry', }, [ErrorCode.MISSING_FIELD]: { code: ErrorCode.MISSING_FIELD, - message: "Required field is missing", + message: 'Required field is missing', httpStatus: 400, - description: "A required field was not provided in the request", - suggestedAction: "Check the request body and include all required fields", + description: 'A required field was not provided in the request', + suggestedAction: 'Check the request body and include all required fields', }, [ErrorCode.VALIDATION_ERROR]: { code: ErrorCode.VALIDATION_ERROR, - message: "Validation failed", + message: 'Validation failed', httpStatus: 400, - description: "Request validation failed", - suggestedAction: "Review the validation errors and correct the input", + description: 'Request validation failed', + suggestedAction: 'Review the validation errors and correct the input', }, // Authentication Errors [ErrorCode.UNAUTHORIZED]: { code: ErrorCode.UNAUTHORIZED, - message: "Unauthorized", + message: 'Unauthorized', httpStatus: 401, - description: "Authentication is required to access this resource", - suggestedAction: "Provide valid authentication credentials", + description: 'Authentication is required to access this resource', + suggestedAction: 'Provide valid authentication credentials', }, [ErrorCode.TOKEN_EXPIRED]: { code: ErrorCode.TOKEN_EXPIRED, - message: "Token has expired", + message: 'Token has expired', httpStatus: 401, - description: "The JWT token has expired", - suggestedAction: "Log in again to obtain a new token", + description: 'The JWT token has expired', + suggestedAction: 'Log in again to obtain a new token', }, [ErrorCode.TOKEN_INVALID]: { code: ErrorCode.TOKEN_INVALID, - message: "Invalid token", + message: 'Invalid token', httpStatus: 401, - description: "The JWT token is invalid or malformed", - suggestedAction: "Log in again to obtain a new token", + description: 'The JWT token is invalid or malformed', + suggestedAction: 'Log in again to obtain a new token', }, [ErrorCode.CHALLENGE_EXPIRED]: { code: ErrorCode.CHALLENGE_EXPIRED, - message: "Challenge has expired", + message: 'Challenge has expired', httpStatus: 401, - description: "The challenge message has expired (valid for 5 minutes)", - suggestedAction: "Request a new challenge and sign it", + description: 'The challenge message has expired (valid for 5 minutes)', + suggestedAction: 'Request a new challenge and sign it', }, // Authorization Errors [ErrorCode.FORBIDDEN]: { code: ErrorCode.FORBIDDEN, - message: "Forbidden", + message: 'Forbidden', httpStatus: 403, - description: "You do not have permission to access this resource", - suggestedAction: "Ensure you have the required permissions", + description: 'You do not have permission to access this resource', + suggestedAction: 'Ensure you have the required permissions', }, [ErrorCode.ACCESS_DENIED]: { code: ErrorCode.ACCESS_DENIED, - message: "Access denied", + message: 'Access denied', httpStatus: 403, - description: "Access to this resource is denied", - suggestedAction: "Contact support if you believe this is an error", + description: 'Access to this resource is denied', + suggestedAction: 'Contact support if you believe this is an error', }, [ErrorCode.BORROWER_MISMATCH]: { code: ErrorCode.BORROWER_MISMATCH, - message: "Borrower public key must match your authenticated wallet", + message: 'Borrower public key must match your authenticated wallet', httpStatus: 403, - description: - "The borrower public key does not match the authenticated user", - suggestedAction: "Ensure the borrower public key matches your wallet", + description: 'The borrower public key does not match the authenticated user', + suggestedAction: 'Ensure the borrower public key matches your wallet', }, // Not Found Errors [ErrorCode.NOT_FOUND]: { code: ErrorCode.NOT_FOUND, - message: "Resource not found", + message: 'Resource not found', httpStatus: 404, - description: "The requested resource does not exist", - suggestedAction: "Verify the resource ID and try again", + description: 'The requested resource does not exist', + suggestedAction: 'Verify the resource ID and try again', }, [ErrorCode.LOAN_NOT_FOUND]: { code: ErrorCode.LOAN_NOT_FOUND, - message: "Loan not found", + message: 'Loan not found', httpStatus: 404, - description: "The specified loan does not exist", - suggestedAction: "Verify the loan ID and try again", + description: 'The specified loan does not exist', + suggestedAction: 'Verify the loan ID and try again', }, [ErrorCode.USER_NOT_FOUND]: { code: ErrorCode.USER_NOT_FOUND, - message: "User not found", + message: 'User not found', httpStatus: 404, - description: "The specified user does not exist", - suggestedAction: "Verify the user ID and try again", + description: 'The specified user does not exist', + suggestedAction: 'Verify the user ID and try again', }, [ErrorCode.POOL_NOT_FOUND]: { code: ErrorCode.POOL_NOT_FOUND, - message: "Pool not found", + message: 'Pool not found', httpStatus: 404, - description: "The specified pool does not exist", - suggestedAction: "Verify the pool address and try again", + description: 'The specified pool does not exist', + suggestedAction: 'Verify the pool address and try again', }, // Conflict Errors [ErrorCode.CONFLICT]: { code: ErrorCode.CONFLICT, - message: "Conflict", + message: 'Conflict', httpStatus: 409, - description: "The request conflicts with the current state of the resource", - suggestedAction: "Review the resource state and retry", + description: 'The request conflicts with the current state of the resource', + suggestedAction: 'Review the resource state and retry', }, [ErrorCode.DUPLICATE_REQUEST]: { code: ErrorCode.DUPLICATE_REQUEST, - message: "Duplicate request", + message: 'Duplicate request', httpStatus: 409, - description: "This request has already been processed", - suggestedAction: "Check if the operation was already completed", + description: 'This request has already been processed', + suggestedAction: 'Check if the operation was already completed', }, // Rate Limiting [ErrorCode.RATE_LIMIT_EXCEEDED]: { code: ErrorCode.RATE_LIMIT_EXCEEDED, - message: "Rate limit exceeded", + message: 'Rate limit exceeded', httpStatus: 429, - description: "Too many requests. Please try again later", - suggestedAction: "Wait before making another request", + description: 'Too many requests. Please try again later', + suggestedAction: 'Wait before making another request', }, // Server Errors [ErrorCode.INTERNAL_ERROR]: { code: ErrorCode.INTERNAL_ERROR, - message: "Internal server error", + message: 'Internal server error', httpStatus: 500, - description: "An unexpected error occurred on the server", - suggestedAction: "Please try again later or contact support", + description: 'An unexpected error occurred on the server', + suggestedAction: 'Please try again later or contact support', }, [ErrorCode.DATABASE_ERROR]: { code: ErrorCode.DATABASE_ERROR, - message: "Database error", + message: 'Database error', httpStatus: 500, - description: "A database error occurred", - suggestedAction: "Please try again later or contact support", + description: 'A database error occurred', + suggestedAction: 'Please try again later or contact support', }, [ErrorCode.EXTERNAL_SERVICE_ERROR]: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, - message: "External service error", + message: 'External service error', httpStatus: 500, - description: "An external service failed to respond", - suggestedAction: "Please try again later or contact support", + description: 'An external service failed to respond', + suggestedAction: 'Please try again later or contact support', }, [ErrorCode.BLOCKCHAIN_ERROR]: { code: ErrorCode.BLOCKCHAIN_ERROR, - message: "Blockchain error", + message: 'Blockchain error', httpStatus: 500, - description: "A blockchain operation failed", - suggestedAction: "Please try again later or contact support", + description: 'A blockchain operation failed', + suggestedAction: 'Please try again later or contact support', }, // Business Logic Errors [ErrorCode.INSUFFICIENT_BALANCE]: { code: ErrorCode.INSUFFICIENT_BALANCE, - message: "Insufficient balance", + message: 'Insufficient balance', httpStatus: 400, - description: "The account has insufficient balance for this operation", - suggestedAction: "Deposit more funds or reduce the amount", + description: 'The account has insufficient balance for this operation', + suggestedAction: 'Deposit more funds or reduce the amount', }, [ErrorCode.LOAN_ALREADY_REPAID]: { code: ErrorCode.LOAN_ALREADY_REPAID, - message: "Loan already repaid", + message: 'Loan already repaid', httpStatus: 400, - description: "This loan has already been fully repaid", - suggestedAction: "No further action is needed for this loan", + description: 'This loan has already been fully repaid', + suggestedAction: 'No further action is needed for this loan', }, [ErrorCode.LOAN_NOT_ACTIVE]: { code: ErrorCode.LOAN_NOT_ACTIVE, - message: "Loan is not active", + message: 'Loan is not active', httpStatus: 400, - description: "This loan is not in an active state", - suggestedAction: "Verify the loan status and try again", + description: 'This loan is not in an active state', + suggestedAction: 'Verify the loan status and try again', }, [ErrorCode.INVALID_LOAN_ID]: { code: ErrorCode.INVALID_LOAN_ID, - message: "Invalid loan ID", + message: 'Invalid loan ID', httpStatus: 400, - description: "The provided loan ID is invalid", - suggestedAction: "Provide a valid numeric loan ID", + description: 'The provided loan ID is invalid', + suggestedAction: 'Provide a valid numeric loan ID', }, [ErrorCode.INVALID_TX_XDR]: { code: ErrorCode.INVALID_TX_XDR, - message: "Invalid transaction XDR", + message: 'Invalid transaction XDR', httpStatus: 400, - description: "The provided transaction XDR is invalid or malformed", - suggestedAction: "Provide a valid signed transaction XDR", + description: 'The provided transaction XDR is invalid or malformed', + suggestedAction: 'Provide a valid signed transaction XDR', }, }; diff --git a/backend/src/index.ts b/backend/src/index.ts index d6c439c6..bc799d7d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,37 +1,37 @@ -import dotenv from "dotenv"; +import dotenv from 'dotenv'; dotenv.config(); -import { validateEnvVars } from "./config/env.js"; +import { validateEnvVars } from './config/env.js'; validateEnvVars(); // Sentry must be initialized before any other imports so it can instrument them -import { initSentry } from "./config/sentry.js"; +import { initSentry } from './config/sentry.js'; initSentry(); -const app = (await import("./app.js")).default; -import logger from "./utils/logger.js"; -import { closePool } from "./db/connection.js"; -import { startIndexer, stopIndexer } from "./services/indexerManager.js"; +const app = (await import('./app.js')).default; +import logger from './utils/logger.js'; +import { closePool } from './db/connection.js'; +import { startIndexer, stopIndexer } from './services/indexerManager.js'; import { startDefaultCheckerScheduler, stopDefaultCheckerScheduler, -} from "./services/defaultChecker.js"; +} from './services/defaultChecker.js'; import { startWebhookRetryScheduler, stopWebhookRetryScheduler, -} from "./services/webhookRetryScheduler.js"; -import { eventStreamService } from "./services/eventStreamService.js"; +} from './services/webhookRetryScheduler.js'; +import { eventStreamService } from './services/eventStreamService.js'; import { startNotificationCleanupScheduler, stopNotificationCleanupScheduler, -} from "./services/notificationService.js"; +} from './services/notificationService.js'; import { startScoreReconciliationScheduler, stopScoreReconciliationScheduler, -} from "./services/scoreReconciliationService.js"; -import { sorobanService } from "./services/sorobanService.js"; -import { validateLoanConfig } from "./config/loanConfig.js"; -import { startLoanDueCheckCron } from "./cron/loanCheckCron.js"; +} from './services/scoreReconciliationService.js'; +import { sorobanService } from './services/sorobanService.js'; +import { validateLoanConfig } from './config/loanConfig.js'; +import { startLoanDueCheckCron } from './cron/loanCheckCron.js'; const port = process.env.PORT || 3001; @@ -40,7 +40,7 @@ try { validateLoanConfig(); sorobanService.validateScoreConfig(); } catch (err) { - logger.error("Startup configuration is invalid, aborting startup.", { err }); + logger.error('Startup configuration is invalid, aborting startup.', { err }); process.exit(1); } @@ -48,7 +48,7 @@ try { try { await sorobanService.validateConfig(); } catch (err) { - logger.error("Soroban configuration is invalid, aborting startup.", { err }); + logger.error('Soroban configuration is invalid, aborting startup.', { err }); process.exit(1); } @@ -74,12 +74,12 @@ const server = app.listen(port, () => { startLoanDueCheckCron(); }); -const shutdown = async (signal: "SIGTERM" | "SIGINT") => { +const shutdown = async (signal: 'SIGTERM' | 'SIGINT') => { logger.info(`${signal} signal received: closing HTTP server`); // Timeout (30s) force-kills if shutdown stalls const timeout = setTimeout(() => { - logger.error("Shutdown stalled for 30s, forcing exit."); + logger.error('Shutdown stalled for 30s, forcing exit.'); process.exit(1); }, 30000); timeout.unref(); @@ -92,15 +92,14 @@ const shutdown = async (signal: "SIGTERM" | "SIGINT") => { stopNotificationCleanupScheduler(); if ( - typeof ( - eventStreamService as unknown as { closeAll: (reason: string) => void } - ).closeAll === "function" + typeof (eventStreamService as unknown as { closeAll: (reason: string) => void }).closeAll === + 'function' ) { - ( - eventStreamService as unknown as { closeAll: (reason: string) => void } - ).closeAll("Server shutting down"); - } else if (typeof eventStreamService.closeAllConnections === "function") { - eventStreamService.closeAllConnections("Server shutting down"); + (eventStreamService as unknown as { closeAll: (reason: string) => void }).closeAll( + 'Server shutting down', + ); + } else if (typeof eventStreamService.closeAllConnections === 'function') { + eventStreamService.closeAllConnections('Server shutting down'); } await new Promise((resolve, reject) => { @@ -114,13 +113,13 @@ const shutdown = async (signal: "SIGTERM" | "SIGINT") => { }); await closePool(); - logger.info("Database pool drained."); + logger.info('Database pool drained.'); process.exit(0); } catch (err) { - logger.error("Graceful shutdown failed", { signal, err }); + logger.error('Graceful shutdown failed', { signal, err }); process.exit(1); } }; -process.on("SIGTERM", () => shutdown("SIGTERM")); -process.on("SIGINT", () => shutdown("SIGINT")); +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/backend/src/middleware/README.md b/backend/src/middleware/README.md index e45f6d5f..4d06beb6 100644 --- a/backend/src/middleware/README.md +++ b/backend/src/middleware/README.md @@ -9,10 +9,10 @@ The `validate` middleware function accepts a Zod schema and validates the reques ### Example ```typescript -import { validate } from "../middleware/validation.js"; -import { mySchema } from "../schemas/mySchemas.js"; +import { validate } from '../middleware/validation.js'; +import { mySchema } from '../schemas/mySchemas.js'; -router.post("/endpoint", validate(mySchema), myController); +router.post('/endpoint', validate(mySchema), myController); ``` ## Schema Structure @@ -26,7 +26,7 @@ Schemas should validate the following request properties: ### Example Schema ```typescript -import { z } from "zod"; +import { z } from 'zod'; export const mySchema = z.object({ body: z.object({ diff --git a/backend/src/middleware/__tests__/rateLimitMiddleware.test.ts b/backend/src/middleware/__tests__/rateLimitMiddleware.test.ts index c338efc6..c10005ef 100644 --- a/backend/src/middleware/__tests__/rateLimitMiddleware.test.ts +++ b/backend/src/middleware/__tests__/rateLimitMiddleware.test.ts @@ -1,8 +1,8 @@ -import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -import type { Request, Response, NextFunction } from "express"; +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { Request, Response, NextFunction } from 'express'; // Mock the rate limit service before importing middleware that depends on it -jest.unstable_mockModule("../../services/rateLimitService.js", () => ({ +jest.unstable_mockModule('../../services/rateLimitService.js', () => ({ rateLimitService: { checkRateLimit: jest.fn(), resetRateLimit: jest.fn(), @@ -15,7 +15,7 @@ jest.unstable_mockModule("../../services/rateLimitService.js", () => ({ })); const mockLoggerInfo = jest.fn(); -jest.unstable_mockModule("../../utils/logger.js", () => ({ +jest.unstable_mockModule('../../utils/logger.js', () => ({ default: { info: mockLoggerInfo, warn: jest.fn(), @@ -23,14 +23,13 @@ jest.unstable_mockModule("../../utils/logger.js", () => ({ }, })); -const { createRateLimitMiddleware, scoreUpdateRateLimit } = - await import("../rateLimitMiddleware.js"); -const { rateLimitService } = await import("../../services/rateLimitService.js"); -const mockRateLimitService = rateLimitService as jest.Mocked< - typeof rateLimitService ->; +const { createRateLimitMiddleware, scoreUpdateRateLimit } = await import( + '../rateLimitMiddleware.js' +); +const { rateLimitService } = await import('../../services/rateLimitService.js'); +const mockRateLimitService = rateLimitService as jest.Mocked; -describe("Rate Limit Middleware", () => { +describe('Rate Limit Middleware', () => { jest.setTimeout(20000); let mockRequest: Partial; let mockResponse: Partial; @@ -40,10 +39,10 @@ describe("Rate Limit Middleware", () => { jest.clearAllMocks(); mockRequest = { - body: { userId: "user123" }, - path: "/api/score/update", - method: "POST", - ip: "127.0.0.1", + body: { userId: 'user123' }, + path: '/api/score/update', + method: 'POST', + ip: '127.0.0.1', }; mockResponse = { @@ -53,8 +52,8 @@ describe("Rate Limit Middleware", () => { mockNext = jest.fn(); }); - describe("createRateLimitMiddleware", () => { - it("should allow request within rate limit", async () => { + describe('createRateLimitMiddleware', () => { + it('should allow request within rate limit', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: true, remaining: 4, @@ -63,22 +62,18 @@ describe("Rate Limit Middleware", () => { }); const middleware = createRateLimitMiddleware(); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); expect(mockNext).toHaveBeenCalledWith(); expect(mockResponse.set).toHaveBeenCalledWith({ - "X-RateLimit-Limit": "5", - "X-RateLimit-Remaining": "4", - "X-RateLimit-Reset": expect.any(String), - "X-RateLimit-Used": "1", + 'X-RateLimit-Limit': '5', + 'X-RateLimit-Remaining': '4', + 'X-RateLimit-Reset': expect.any(String), + 'X-RateLimit-Used': '1', }); }); - it("should block request exceeding rate limit", async () => { + it('should block request exceeding rate limit', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: false, remaining: 0, @@ -87,21 +82,17 @@ describe("Rate Limit Middleware", () => { }); const middleware = createRateLimitMiddleware(); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); expect(mockNext).toHaveBeenCalledWith( expect.objectContaining({ statusCode: 429, - message: "Rate limit exceeded. Please try again later.", + message: 'Rate limit exceeded. Please try again later.', }), ); }); - it("should use custom identifier function", async () => { + it('should use custom identifier function', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: true, remaining: 4, @@ -113,19 +104,15 @@ describe("Rate Limit Middleware", () => { getIdentifier: (req) => `custom:${req.body?.userId}`, }); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); expect(mockRateLimitService.checkRateLimit).toHaveBeenCalledWith( - "custom:user123", + 'custom:user123', expect.any(Object), ); }); - it("should use custom configuration", async () => { + it('should use custom configuration', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: true, remaining: 9, @@ -137,71 +124,53 @@ describe("Rate Limit Middleware", () => { config: { maxRequests: 10, windowSeconds: 3600 }, }); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); - expect(mockRateLimitService.checkRateLimit).toHaveBeenCalledWith( - "user123", - { maxRequests: 10, windowSeconds: 3600 }, - ); + expect(mockRateLimitService.checkRateLimit).toHaveBeenCalledWith('user123', { + maxRequests: 10, + windowSeconds: 3600, + }); expect(mockResponse.set).toHaveBeenCalledWith({ - "X-RateLimit-Limit": "10", - "X-RateLimit-Remaining": "9", - "X-RateLimit-Reset": expect.any(String), - "X-RateLimit-Used": "1", + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': '9', + 'X-RateLimit-Reset': expect.any(String), + 'X-RateLimit-Used': '1', }); }); - it("should skip rate limiting when condition is met", async () => { + it('should skip rate limiting when condition is met', async () => { const middleware = createRateLimitMiddleware({ - skipIf: (req) => req.body?.userId === "admin", + skipIf: (req) => req.body?.userId === 'admin', }); - mockRequest.body = { userId: "admin" }; + mockRequest.body = { userId: 'admin' }; - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); expect(mockRateLimitService.checkRateLimit).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalledWith(); }); - it("should fail open when rate limit service fails", async () => { - mockRateLimitService.checkRateLimit.mockRejectedValue( - new Error("Redis error"), - ); + it('should fail open when rate limit service fails', async () => { + mockRateLimitService.checkRateLimit.mockRejectedValue(new Error('Redis error')); const middleware = createRateLimitMiddleware(); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); expect(mockNext).toHaveBeenCalledWith(); }); - it("should handle missing userId gracefully", async () => { + it('should handle missing userId gracefully', async () => { mockRequest.body = {}; const middleware = createRateLimitMiddleware(); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); // Middleware fails open when getIdentifier throws expect(mockNext).toHaveBeenCalledWith(); }); - it("should log when rate limit is nearing exhaustion", async () => { + it('should log when rate limit is nearing exhaustion', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: true, remaining: 0, // 90% of 5 is 4.5, so 0 remaining triggers the log @@ -210,16 +179,12 @@ describe("Rate Limit Middleware", () => { }); const middleware = createRateLimitMiddleware(); - await middleware( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await middleware(mockRequest as Request, mockResponse as Response, mockNext); expect(mockLoggerInfo).toHaveBeenCalledWith( - "Rate limit nearing exhaustion", + 'Rate limit nearing exhaustion', expect.objectContaining({ - identifier: "user123", + identifier: 'user123', remaining: 0, maxRequests: 5, }), @@ -227,8 +192,8 @@ describe("Rate Limit Middleware", () => { }); }); - describe("scoreUpdateRateLimit", () => { - it("should use score update specific configuration", async () => { + describe('scoreUpdateRateLimit', () => { + it('should use score update specific configuration', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: true, remaining: 4, @@ -236,25 +201,21 @@ describe("Rate Limit Middleware", () => { currentCount: 1, }); - await scoreUpdateRateLimit( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await scoreUpdateRateLimit(mockRequest as Request, mockResponse as Response, mockNext); - expect(mockRateLimitService.checkRateLimit).toHaveBeenCalledWith( - "user123", - { maxRequests: 5, windowSeconds: 86400 }, - ); + expect(mockRateLimitService.checkRateLimit).toHaveBeenCalledWith('user123', { + maxRequests: 5, + windowSeconds: 86400, + }); expect(mockResponse.set).toHaveBeenCalledWith({ - "X-RateLimit-Limit": "5", - "X-RateLimit-Remaining": "4", - "X-RateLimit-Reset": expect.any(String), - "X-RateLimit-Used": "1", + 'X-RateLimit-Limit': '5', + 'X-RateLimit-Remaining': '4', + 'X-RateLimit-Reset': expect.any(String), + 'X-RateLimit-Used': '1', }); }); - it("should use custom error message for score updates", async () => { + it('should use custom error message for score updates', async () => { mockRateLimitService.checkRateLimit.mockResolvedValue({ allowed: false, remaining: 0, @@ -262,17 +223,12 @@ describe("Rate Limit Middleware", () => { currentCount: 6, }); - await scoreUpdateRateLimit( - mockRequest as Request, - mockResponse as Response, - mockNext, - ); + await scoreUpdateRateLimit(mockRequest as Request, mockResponse as Response, mockNext); expect(mockNext).toHaveBeenCalledWith( expect.objectContaining({ statusCode: 429, - message: - "Too many score updates. Maximum 5 updates allowed per user per day.", + message: 'Too many score updates. Maximum 5 updates allowed per user per day.', }), ); }); diff --git a/backend/src/middleware/auditLog.ts b/backend/src/middleware/auditLog.ts index 6f78908f..7b6e994b 100644 --- a/backend/src/middleware/auditLog.ts +++ b/backend/src/middleware/auditLog.ts @@ -1,27 +1,20 @@ -import type { Request, Response, NextFunction } from "express"; -import { query } from "../db/connection.js"; -import logger from "../utils/logger.js"; +import type { Request, Response, NextFunction } from 'express'; +import { query } from '../db/connection.js'; +import logger from '../utils/logger.js'; /** * Sanitizes the request body to remove sensitive fields before logging. */ function sanitizePayload(body: unknown): unknown { - if (!body || typeof body !== "object") return body; + if (!body || typeof body !== 'object') return body; const sanitized = { ...body } as Record; // List of fields that should be redacted in audit logs - const sensitiveFields = [ - "secret", - "apiKey", - "password", - "token", - "signedTxXdr", - "x-api-key", - ]; + const sensitiveFields = ['secret', 'apiKey', 'password', 'token', 'signedTxXdr', 'x-api-key']; for (const field of sensitiveFields) { if (field in sanitized) { - sanitized[field] = "[REDACTED]"; + sanitized[field] = '[REDACTED]'; } } @@ -44,8 +37,7 @@ function extractTarget(req: Request): string | undefined { const body = req.body as Record; if (body) { if (body.loanId) return `LoanID:${body.loanId}`; - if (Array.isArray(body.loanIds)) - return `LoanIDs:[${body.loanIds.join(",")}]`; + if (Array.isArray(body.loanIds)) return `LoanIDs:[${body.loanIds.join(',')}]`; if (body.address) return `Address:${body.address}`; if (body.userId) return `UserID:${body.userId}`; if (body.publicKey) return `PublicKey:${body.publicKey}`; @@ -60,24 +52,19 @@ function extractTarget(req: Request): string | undefined { * It identifies the actor (JWT user or API key), the action (method+path), * any target entity, and the sanitized request payload. */ -export const auditLog = async ( - req: Request, - _res: Response, - next: NextFunction, -): Promise => { +export const auditLog = async (req: Request, _res: Response, next: NextFunction): Promise => { try { const actor = - req.user?.publicKey ?? - (req.headers["x-api-key"] ? "INTERNAL_API_KEY" : "unknown"); + req.user?.publicKey ?? (req.headers['x-api-key'] ? 'INTERNAL_API_KEY' : 'unknown'); const action = `${req.method} ${req.path}`; const target = extractTarget(req); const payload = sanitizePayload(req.body); const ipAddress = req.ip || - (Array.isArray(req.headers["x-forwarded-for"]) - ? req.headers["x-forwarded-for"][0] - : (req.headers["x-forwarded-for"] as string) - )?.split(",")[0] || + (Array.isArray(req.headers['x-forwarded-for']) + ? req.headers['x-forwarded-for'][0] + : (req.headers['x-forwarded-for'] as string) + )?.split(',')[0] || req.socket.remoteAddress; // Log the action asynchronously to avoid blocking the main request thread @@ -95,7 +82,7 @@ export const auditLog = async ( ], ); } catch (err) { - logger.error("Audit logging failure", { + logger.error('Audit logging failure', { err, actor, action, @@ -105,7 +92,7 @@ export const auditLog = async ( })(); } catch (err) { // If the audit log logic fails, we still want to proceed with the request - logger.warn("Audit log middleware error", { err }); + logger.warn('Audit log middleware error', { err }); } next(); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 8bee6316..45b13007 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,17 +1,13 @@ -import crypto from "node:crypto"; -import type { Request, Response, NextFunction } from "express"; -import { AppError } from "../errors/AppError.js"; +import crypto from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; +import { AppError } from '../errors/AppError.js'; /** * Admin API key scopes. * A key without a scope prefix is treated as a legacy key that grants all scopes. * A scoped key has the format `:` and grants only that one scope. */ -export type ApiKeyScope = - | "admin:disputes" - | "admin:indexer" - | "admin:webhooks" - | "admin:loans"; +export type ApiKeyScope = 'admin:disputes' | 'admin:indexer' | 'admin:webhooks' | 'admin:loans'; interface ParsedKey { scope: ApiKeyScope | null; // null = legacy (all scopes) @@ -23,14 +19,13 @@ function parseConfiguredKeys(): ParsedKey[] { if (!raw) return []; return raw - .split(",") + .split(',') .map((entry) => entry.trim()) .filter(Boolean) .map((entry): ParsedKey => { // Scoped format: "::". - const firstColon = entry.indexOf(":"); - const secondColon = - firstColon >= 0 ? entry.indexOf(":", firstColon + 1) : -1; + const firstColon = entry.indexOf(':'); + const secondColon = firstColon >= 0 ? entry.indexOf(':', firstColon + 1) : -1; if (firstColon >= 0 && secondColon > firstColon) { const scope = entry.slice(0, secondColon) as ApiKeyScope; @@ -56,14 +51,12 @@ export const requireApiKey = (requiredScope?: ApiKeyScope) => { const configuredKeys = parseConfiguredKeys(); if (configuredKeys.length === 0) { - throw AppError.internal( - "Server misconfiguration: INTERNAL_API_KEY is not set", - ); + throw AppError.internal('Server misconfiguration: INTERNAL_API_KEY is not set'); } - const providedKey = req.headers["x-api-key"]; + const providedKey = req.headers['x-api-key']; if (!providedKey) { - throw AppError.unauthorized("Unauthorised: missing API key"); + throw AppError.unauthorized('Unauthorised: missing API key'); } const keyStr = Array.isArray(providedKey) ? providedKey[0] : providedKey; @@ -84,17 +77,14 @@ export const requireApiKey = (requiredScope?: ApiKeyScope) => { if (!match) { if (valueMatched && requiredScope !== undefined) { - throw AppError.forbidden( - `Unauthorised: API key lacks required scope ${requiredScope}`, - ); + throw AppError.forbidden(`Unauthorised: API key lacks required scope ${requiredScope}`); } - throw AppError.unauthorized("Unauthorised: invalid or missing API key"); + throw AppError.unauthorized('Unauthorised: invalid or missing API key'); } if (requiredScope !== undefined) { - (req as Request & { apiKeyScope?: ApiKeyScope }).apiKeyScope = - requiredScope; + (req as Request & { apiKeyScope?: ApiKeyScope }).apiKeyScope = requiredScope; } next(); diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index b27dffe8..496c2ee2 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,9 +1,9 @@ -import type { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { AppError } from "../errors/AppError.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import logger from "../utils/logger.js"; -import { Sentry } from "../config/sentry.js"; +import type { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AppError } from '../errors/AppError.js'; +import { ErrorCode } from '../errors/errorCodes.js'; +import logger from '../utils/logger.js'; +import { Sentry } from '../config/sentry.js'; /** * Global error handling middleware. @@ -25,7 +25,7 @@ export const errorHandler = ( // ── Zod Validation Errors ──────────────────────────────────── if (err instanceof z.ZodError) { const details = err.issues.map((issue: z.ZodIssue) => ({ - field: issue.path.join("."), + field: issue.path.join('.'), message: issue.message, code: issue.code, })); @@ -36,15 +36,15 @@ export const errorHandler = ( res.status(400).json({ success: false, // Legacy format for backward compatibility - message: "Validation failed", + message: 'Validation failed', errors: err.issues.map((issue: z.ZodIssue) => ({ - path: issue.path.join("."), + path: issue.path.join('.'), message: issue.message, })), // New structured format error: { code: ErrorCode.VALIDATION_ERROR, - message: "Validation failed", + message: 'Validation failed', field: firstField, details, }, @@ -68,13 +68,13 @@ export const errorHandler = ( const errorDetail: Record = { code: err.errorCode, - message: err.isOperational ? err.message : "Internal server error", + message: err.isOperational ? err.message : 'Internal server error', }; const errorResponse: Record = { success: false, // Legacy format for backward compatibility - message: err.isOperational ? err.message : "Internal server error", + message: err.isOperational ? err.message : 'Internal server error', // New structured format error: errorDetail, }; @@ -95,7 +95,7 @@ export const errorHandler = ( } // ── Unexpected / Programming Errors ────────────────────────── - logger.error("Unhandled error", { + logger.error('Unhandled error', { requestId: req.requestId, message: err.message, name: err.name, @@ -105,16 +105,15 @@ export const errorHandler = ( Sentry.captureException(err); const shouldExposeStackTrace = - process.env.NODE_ENV === "development" && - process.env.EXPOSE_STACK_TRACES === "true"; + process.env.NODE_ENV === 'development' && process.env.EXPOSE_STACK_TRACES === 'true'; res.status(500).json({ success: false, // Legacy format - message: "Internal server error", + message: 'Internal server error', error: { code: ErrorCode.INTERNAL_ERROR, - message: "Internal server error", + message: 'Internal server error', }, ...(shouldExposeStackTrace && { stack: err.stack }), }); diff --git a/backend/src/middleware/idempotency.ts b/backend/src/middleware/idempotency.ts index b78c3c1e..5fd2800a 100644 --- a/backend/src/middleware/idempotency.ts +++ b/backend/src/middleware/idempotency.ts @@ -1,6 +1,6 @@ -import type { Request, Response, NextFunction } from "express"; -import { cacheService } from "../services/cacheService.js"; -import logger from "../utils/logger.js"; +import type { Request, Response, NextFunction } from 'express'; +import { cacheService } from '../services/cacheService.js'; +import logger from '../utils/logger.js'; const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hours in seconds @@ -19,7 +19,7 @@ export const idempotencyMiddleware = async ( res: Response, next: NextFunction, ): Promise => { - const key = req.header("Idempotency-Key"); + const key = req.header('Idempotency-Key'); if (!key) { return next(); @@ -40,8 +40,8 @@ export const idempotencyMiddleware = async ( // Clients can use this to de-duplicate toasts and avoid double-counting. res .status(cached.status) - .set("X-Idempotency-Cache", "HIT") - .set("X-Idempotent-Replayed", "true") + .set('X-Idempotency-Cache', 'HIT') + .set('X-Idempotent-Replayed', 'true') .json(cached.body); return; } @@ -61,7 +61,7 @@ export const idempotencyMiddleware = async ( // Override res.send (as res.json eventually calls res.send) res.send = function (body: unknown) { if (!responseBody) { - if (typeof body === "string") { + if (typeof body === 'string') { try { responseBody = JSON.parse(body); } catch { @@ -76,10 +76,10 @@ export const idempotencyMiddleware = async ( // X-Idempotent-Replayed: false on the first (fresh) execution so the // client always receives the header and can branch on its value. - res.set("X-Idempotent-Replayed", "false"); + res.set('X-Idempotent-Replayed', 'false'); // Store the response in cache once the request is finished - res.on("finish", async () => { + res.on('finish', async () => { // Only cache 2xx and 4xx status codes. // 5xx errors should usually be retried without returning a cached failure. if (res.statusCode >= 200 && res.statusCode < 500 && responseBody) { @@ -100,7 +100,7 @@ export const idempotencyMiddleware = async ( next(); } catch (error) { - logger.error("Error in idempotency middleware", { error, key }); + logger.error('Error in idempotency middleware', { error, key }); next(); } }; diff --git a/backend/src/middleware/jwtAuth.ts b/backend/src/middleware/jwtAuth.ts index e30bcd99..65d9c34f 100644 --- a/backend/src/middleware/jwtAuth.ts +++ b/backend/src/middleware/jwtAuth.ts @@ -12,7 +12,7 @@ import { type JwtPayload, } from "../services/authService.js"; -const DEFAULT_JWT_COOKIE_NAME = "remitlend_jwt"; +const DEFAULT_JWT_COOKIE_NAME = 'remitlend_jwt'; function extractCookieToken(cookieHeader: string | undefined): string | null { if (!cookieHeader) { @@ -20,16 +20,16 @@ function extractCookieToken(cookieHeader: string | undefined): string | null { } const cookieName = process.env.JWT_COOKIE_NAME ?? DEFAULT_JWT_COOKIE_NAME; - const cookiePairs = cookieHeader.split(";"); + const cookiePairs = cookieHeader.split(';'); for (const pair of cookiePairs) { - const [rawKey, ...rawValueParts] = pair.split("="); + const [rawKey, ...rawValueParts] = pair.split('='); const key = rawKey?.trim(); if (key !== cookieName) { continue; } - const rawValue = rawValueParts.join("=").trim(); + const rawValue = rawValueParts.join('=').trim(); if (!rawValue) { return null; } @@ -44,7 +44,7 @@ function extractCookieToken(cookieHeader: string | undefined): string | null { return null; } -declare module "express" { +declare module 'express' { interface Request { user?: JwtPayload; } @@ -81,12 +81,12 @@ export const requireJwtAuth = async ( // Query-string tokens are intentionally rejected to avoid URL token leaks. const token = extractBearerToken(authHeader) ?? cookieToken ?? null; if (!token) { - throw AppError.unauthorized("Missing or invalid Authorization header"); + throw AppError.unauthorized('Missing or invalid Authorization header'); } const payload = verifyJwtToken(token); if (!payload) { - throw AppError.unauthorized("Invalid or expired token"); + throw AppError.unauthorized('Invalid or expired token'); } if (payload.jti && (await isTokenRevoked(payload.jti))) { @@ -117,11 +117,7 @@ export const optionalJwtAuth = async ( next(); }; -export const requireWalletOwnership = ( - req: Request, - _res: Response, - next: NextFunction, -): void => { +export const requireWalletOwnership = (req: Request, _res: Response, next: NextFunction): void => { const requestedWallet = req.params.borrower ?? req.params.wallet ?? @@ -129,15 +125,15 @@ export const requireWalletOwnership = ( const authenticatedWallet = req.user?.publicKey; if (!authenticatedWallet) { - throw AppError.unauthorized("Authentication required"); + throw AppError.unauthorized('Authentication required'); } if (!requestedWallet) { - throw AppError.badRequest("Wallet address is required"); + throw AppError.badRequest('Wallet address is required'); } if (requestedWallet !== authenticatedWallet) { - throw AppError.forbidden("You are not authorized to access this wallet"); + throw AppError.forbidden('You are not authorized to access this wallet'); } next(); @@ -152,7 +148,7 @@ export const requireWalletParamMatchesJwt = (paramName: string) => { const authenticatedWallet = req.user?.publicKey; if (!authenticatedWallet) { - throw AppError.unauthorized("Authentication required"); + throw AppError.unauthorized('Authentication required'); } if (!requested) { @@ -160,38 +156,26 @@ export const requireWalletParamMatchesJwt = (paramName: string) => { } if (requested !== authenticatedWallet) { - throw AppError.forbidden( - "You are not authorized to access this resource", - ); + throw AppError.forbidden('You are not authorized to access this resource'); } next(); }; }; -export const requireBorrower = ( - req: Request, - _res: Response, - next: NextFunction, -): void => { - if (!req.user?.publicKey) - throw AppError.unauthorized("Authentication required"); - if (req.user.role !== "borrower" && req.user.role !== "admin") { - throw AppError.forbidden("Borrower role required"); +export const requireBorrower = (req: Request, _res: Response, next: NextFunction): void => { + if (!req.user?.publicKey) throw AppError.unauthorized('Authentication required'); + if (req.user.role !== 'borrower' && req.user.role !== 'admin') { + throw AppError.forbidden('Borrower role required'); } next(); }; -export const requireLender = ( - req: Request, - _res: Response, - next: NextFunction, -): void => { - if (!req.user?.publicKey) - throw AppError.unauthorized("Authentication required"); - if (req.user.role !== "lender" && req.user.role !== "admin") { - throw AppError.forbidden("Lender role required"); +export const requireLender = (req: Request, _res: Response, next: NextFunction): void => { + if (!req.user?.publicKey) throw AppError.unauthorized('Authentication required'); + if (req.user.role !== 'lender' && req.user.role !== 'admin') { + throw AppError.forbidden('Lender role required'); } next(); @@ -200,11 +184,11 @@ export const requireLender = ( export const requireRoles = (...roles: UserRole[]) => { return (req: Request, _res: Response, next: NextFunction): void => { if (!req.user?.publicKey) { - throw AppError.unauthorized("Authentication required"); + throw AppError.unauthorized('Authentication required'); } if (!roles.includes(req.user.role)) { - throw AppError.forbidden("Insufficient role permissions"); + throw AppError.forbidden('Insufficient role permissions'); } next(); @@ -214,17 +198,15 @@ export const requireRoles = (...roles: UserRole[]) => { export const requireScopes = (...requiredScopes: string[]) => { return (req: Request, _res: Response, next: NextFunction): void => { if (!req.user?.publicKey) { - throw AppError.unauthorized("Authentication required"); + throw AppError.unauthorized('Authentication required'); } const grantedScopes = new Set(req.user.scopes ?? []); - if (grantedScopes.has("admin:all")) { + if (grantedScopes.has('admin:all')) { return next(); } - const missingScope = requiredScopes.find( - (scope) => !grantedScopes.has(scope), - ); + const missingScope = requiredScopes.find((scope) => !grantedScopes.has(scope)); if (missingScope) { throw AppError.forbidden(`Missing required scope: ${missingScope}`); diff --git a/backend/src/middleware/loanAccess.ts b/backend/src/middleware/loanAccess.ts index 01a7d1df..3be6bf1d 100644 --- a/backend/src/middleware/loanAccess.ts +++ b/backend/src/middleware/loanAccess.ts @@ -1,7 +1,7 @@ -import { query } from "../db/connection.js"; -import { AppError } from "../errors/AppError.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; +import { query } from '../db/connection.js'; +import { AppError } from '../errors/AppError.js'; +import { ErrorCode } from '../errors/errorCodes.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; /** * After `requireJwtAuth`, ensures `req.params.loanId` refers to a loan whose @@ -9,43 +9,35 @@ import { asyncHandler } from "../utils/asyncHandler.js"; * Returns 404 when the loan is missing and 403 when it belongs to a different * borrower. */ -export const requireLoanBorrowerAccess = asyncHandler( - async (req, res, next) => { - const loanId = req.params.loanId; - const pk = req.user?.publicKey; - const role = req.user?.role; +export const requireLoanBorrowerAccess = asyncHandler(async (req, res, next) => { + const loanId = req.params.loanId; + const pk = req.user?.publicKey; + const role = req.user?.role; - if (!pk) { - throw AppError.unauthorized("Authentication required"); - } - if (!loanId) { - throw AppError.badRequest("Loan ID is required"); - } + if (!pk) { + throw AppError.unauthorized('Authentication required'); + } + if (!loanId) { + throw AppError.badRequest('Loan ID is required'); + } - // Admins and lenders are allowed to view loan details. - if (role === "admin" || role === "lender") { - return next(); - } + // Admins and lenders are allowed to view loan details. + if (role === 'admin' || role === 'lender') { + return next(); + } - const r = await query( - `SELECT address FROM contract_events WHERE loan_id = $1 LIMIT 1`, - [loanId], - ); + const r = await query(`SELECT address FROM contract_events WHERE loan_id = $1 LIMIT 1`, [loanId]); - const row = r.rows[0] as { address: string } | undefined; - if (!row) { - throw AppError.notFound("Loan not found"); - } - if (row.address !== pk) { - throw AppError.forbidden( - "You are not authorized to access this loan", - ErrorCode.ACCESS_DENIED, - ); - } + const row = r.rows[0] as { address: string } | undefined; + if (!row) { + throw AppError.notFound('Loan not found'); + } + if (row.address !== pk) { + throw AppError.forbidden('You are not authorized to access this loan', ErrorCode.ACCESS_DENIED); + } - next(); - }, -); + next(); +}); /** * After `requireJwtAuth`, ensures the authenticated user owns the loan specified in params. @@ -57,28 +49,22 @@ export const requireLoanOwner = asyncHandler(async (req, res, next) => { const pk = req.user?.publicKey; if (!pk) { - throw AppError.unauthorized("Authentication required"); + throw AppError.unauthorized('Authentication required'); } if (!loanId) { - throw AppError.badRequest("Loan ID is required"); + throw AppError.badRequest('Loan ID is required'); } // Fetch loan borrower from the unified view - const r = await query( - `SELECT address FROM loan_events WHERE loan_id = $1 LIMIT 1`, - [loanId], - ); + const r = await query(`SELECT address FROM loan_events WHERE loan_id = $1 LIMIT 1`, [loanId]); const row = r.rows[0] as { address: string } | undefined; if (!row) { - throw AppError.notFound("Loan not found"); + throw AppError.notFound('Loan not found'); } if (row.address !== pk) { - throw AppError.forbidden( - "You are not authorized to access this loan", - ErrorCode.ACCESS_DENIED, - ); + throw AppError.forbidden('You are not authorized to access this loan', ErrorCode.ACCESS_DENIED); } next(); diff --git a/backend/src/middleware/metrics.ts b/backend/src/middleware/metrics.ts index f40357a6..3903ea19 100644 --- a/backend/src/middleware/metrics.ts +++ b/backend/src/middleware/metrics.ts @@ -1,71 +1,67 @@ -import type { NextFunction, Request, Response } from "express"; -import client from "prom-client"; -import { query } from "../db/connection.js"; -import logger from "../utils/logger.js"; +import type { NextFunction, Request, Response } from 'express'; +import client from 'prom-client'; +import { query } from '../db/connection.js'; +import logger from '../utils/logger.js'; export const metricsRegistry = new client.Registry(); client.collectDefaultMetrics({ register: metricsRegistry }); export const indexerLastLedgerGauge = new client.Gauge({ - name: "indexer_last_ledger", - help: "Last ledger successfully processed by the event indexer.", + name: 'indexer_last_ledger', + help: 'Last ledger successfully processed by the event indexer.', registers: [metricsRegistry], }); export const indexerChainTipGauge = new client.Gauge({ - name: "indexer_chain_tip", - help: "Latest ledger observed from the chain RPC.", + name: 'indexer_chain_tip', + help: 'Latest ledger observed from the chain RPC.', registers: [metricsRegistry], }); export const indexerLagLedgersGauge = new client.Gauge({ - name: "indexer_lag_ledgers", - help: "Difference between the latest chain ledger and the last indexed ledger.", + name: 'indexer_lag_ledgers', + help: 'Difference between the latest chain ledger and the last indexed ledger.', registers: [metricsRegistry], }); export const webhookRetryQueueDepthGauge = new client.Gauge({ - name: "webhook_retry_queue_depth", - help: "Number of webhook deliveries currently waiting for retry.", + name: 'webhook_retry_queue_depth', + help: 'Number of webhook deliveries currently waiting for retry.', registers: [metricsRegistry], }); export const scoreReconciliationLastRunTimestampGauge = new client.Gauge({ - name: "score_reconciliation_last_run_timestamp", - help: "Unix timestamp in seconds for the last score reconciliation run.", + name: 'score_reconciliation_last_run_timestamp', + help: 'Unix timestamp in seconds for the last score reconciliation run.', registers: [metricsRegistry], }); export const httpRequestDurationHistogram = new client.Histogram({ - name: "http_request_duration_seconds", - help: "HTTP request duration in seconds.", - labelNames: ["method", "route", "status_class"] as const, + name: 'http_request_duration_seconds', + help: 'HTTP request duration in seconds.', + labelNames: ['method', 'route', 'status_class'] as const, buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], registers: [metricsRegistry], }); function routeLabel(req: Request): string { const routePath = req.route?.path; - if (typeof routePath === "string") { + if (typeof routePath === 'string') { return `${req.baseUrl}${routePath}` || req.path; } if (Array.isArray(routePath)) { - return `${req.baseUrl}${routePath.join("|")}`; + return `${req.baseUrl}${routePath.join('|')}`; } - return "unmatched"; + return 'unmatched'; } -export function metricsMiddleware( - req: Request, - res: Response, - next: NextFunction, -): void { +export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void { const endTimer = httpRequestDurationHistogram.startTimer(); - res.on("finish", () => { + res.on('finish', () => { endTimer({ method: req.method, route: routeLabel(req), @@ -76,19 +72,14 @@ export function metricsMiddleware( next(); } -export function recordIndexerLedgers( - lastLedger: number, - chainTip: number, -): void { +export function recordIndexerLedgers(lastLedger: number, chainTip: number): void { indexerLastLedgerGauge.set(lastLedger); indexerChainTipGauge.set(chainTip); indexerLagLedgersGauge.set(Math.max(chainTip - lastLedger, 0)); } export function recordScoreReconciliationRun(date = new Date()): void { - scoreReconciliationLastRunTimestampGauge.set( - Math.floor(date.getTime() / 1000), - ); + scoreReconciliationLastRunTimestampGauge.set(Math.floor(date.getTime() / 1000)); } export async function refreshWebhookRetryQueueDepth(): Promise { @@ -102,16 +93,13 @@ export async function refreshWebhookRetryQueueDepth(): Promise { ); webhookRetryQueueDepthGauge.set(Number(result.rows[0]?.count ?? 0)); } catch (error) { - logger.warn("Failed to refresh webhook retry queue depth metric", { + logger.warn('Failed to refresh webhook retry queue depth metric', { error, }); } } -export async function metricsHandler( - _req: Request, - res: Response, -): Promise { - res.set("Content-Type", metricsRegistry.contentType); +export async function metricsHandler(_req: Request, res: Response): Promise { + res.set('Content-Type', metricsRegistry.contentType); res.send(await metricsRegistry.metrics()); } diff --git a/backend/src/middleware/rateLimitMiddleware.ts b/backend/src/middleware/rateLimitMiddleware.ts index baaa5df3..faf2d31c 100644 --- a/backend/src/middleware/rateLimitMiddleware.ts +++ b/backend/src/middleware/rateLimitMiddleware.ts @@ -1,11 +1,8 @@ -import type { Request, Response, NextFunction } from "express"; -import { - rateLimitService, - SCORE_UPDATE_RATE_LIMIT, -} from "../services/rateLimitService.js"; -import { AppError } from "../errors/AppError.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import logger from "../utils/logger.js"; +import type { Request, Response, NextFunction } from 'express'; +import { rateLimitService, SCORE_UPDATE_RATE_LIMIT } from '../services/rateLimitService.js'; +import { AppError } from '../errors/AppError.js'; +import { ErrorCode } from '../errors/errorCodes.js'; +import logger from '../utils/logger.js'; /** * Rate limiting middleware configuration @@ -42,23 +39,19 @@ interface RateLimitMiddlewareOptions { * @param options Rate limiting configuration options * @returns Express middleware function */ -export const createRateLimitMiddleware = ( - options: RateLimitMiddlewareOptions = {}, -) => { +export const createRateLimitMiddleware = (options: RateLimitMiddlewareOptions = {}) => { const { getIdentifier = (req: Request) => { // Default: extract userId from request body for score updates const body = req.body as { userId?: string } | undefined; if (!body?.userId) { - throw new Error( - "Rate limiting middleware requires userId in request body", - ); + throw new Error('Rate limiting middleware requires userId in request body'); } return body.userId; }, config = SCORE_UPDATE_RATE_LIMIT, skipIf = () => false, - errorMessage = "Rate limit exceeded. Please try again later.", + errorMessage = 'Rate limit exceeded. Please try again later.', } = options; return async (req: Request, res: Response, next: NextFunction) => { @@ -76,17 +69,15 @@ export const createRateLimitMiddleware = ( // Add rate limit headers to response res.set({ - "X-RateLimit-Limit": config.maxRequests.toString(), - "X-RateLimit-Remaining": result.remaining.toString(), - "X-RateLimit-Reset": Math.ceil( - result.resetTime.getTime() / 1000, - ).toString(), - "X-RateLimit-Used": result.currentCount.toString(), + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': Math.ceil(result.resetTime.getTime() / 1000).toString(), + 'X-RateLimit-Used': result.currentCount.toString(), }); // Block request if rate limit is exceeded if (!result.allowed) { - logger.warn("Rate limit exceeded", { + logger.warn('Rate limit exceeded', { identifier, currentCount: result.currentCount, maxRequests: config.maxRequests, @@ -101,7 +92,7 @@ export const createRateLimitMiddleware = ( // Log rate limit status for monitoring if (result.remaining <= Math.ceil(config.maxRequests * 0.1)) { // Log when 90% used - logger.info("Rate limit nearing exhaustion", { + logger.info('Rate limit nearing exhaustion', { identifier, remaining: result.remaining, maxRequests: config.maxRequests, @@ -118,7 +109,7 @@ export const createRateLimitMiddleware = ( } // Log unexpected errors and fail open (allow the request) - logger.error("Rate limiting middleware error", { + logger.error('Rate limiting middleware error', { error: error instanceof Error ? error.message : String(error), path: req.path, method: req.method, @@ -136,8 +127,7 @@ export const createRateLimitMiddleware = ( */ export const scoreUpdateRateLimit = createRateLimitMiddleware({ config: SCORE_UPDATE_RATE_LIMIT, - errorMessage: - "Too many score updates. Maximum 5 updates allowed per user per day.", + errorMessage: 'Too many score updates. Maximum 5 updates allowed per user per day.', }); /** @@ -150,12 +140,9 @@ export const createIpRateLimitMiddleware = ( ) => createRateLimitMiddleware({ getIdentifier: (req: Request) => { - const ip = - req.ip || req.connection.remoteAddress || req.socket.remoteAddress; + const ip = req.ip || req.connection.remoteAddress || req.socket.remoteAddress; if (!ip) { - throw new Error( - "Unable to determine client IP address for rate limiting", - ); + throw new Error('Unable to determine client IP address for rate limiting'); } return `ip:${ip}`; }, diff --git a/backend/src/middleware/rateLimiter.ts b/backend/src/middleware/rateLimiter.ts index 51936fe9..77913463 100644 --- a/backend/src/middleware/rateLimiter.ts +++ b/backend/src/middleware/rateLimiter.ts @@ -1,10 +1,10 @@ -import rateLimit, { ipKeyGenerator } from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; export const createRateLimiter = (max: number, windowMinutes: number = 15) => rateLimit({ windowMs: windowMinutes * 60 * 1000, max, - message: { error: "Too many requests, please try again later." }, + message: { error: 'Too many requests, please try again later.' }, standardHeaders: true, legacyHeaders: false, }); @@ -16,15 +16,15 @@ export const strictRateLimiter = createRateLimiter(10, 45); export const challengeRateLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 10, - keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"), + keyGenerator: (req) => ipKeyGenerator(req.ip ?? 'unknown'), message: { success: false, - message: "Too many challenge requests, please try again later.", + message: 'Too many challenge requests, please try again later.', }, standardHeaders: true, legacyHeaders: false, handler: (req, res, _next, options) => { - res.setHeader("Retry-After", Math.ceil(options.windowMs / 1000)); + res.setHeader('Retry-After', Math.ceil(options.windowMs / 1000)); res.status(429).json(options.message); }, }); @@ -33,15 +33,15 @@ export const loginRateLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 5, keyGenerator: (req) => - `${ipKeyGenerator(req.ip ?? "unknown")}:${req.body?.publicKey ?? "unknown"}`, + `${ipKeyGenerator(req.ip ?? 'unknown')}:${req.body?.publicKey ?? 'unknown'}`, message: { success: false, - message: "Too many login attempts, please try again later.", + message: 'Too many login attempts, please try again later.', }, standardHeaders: true, legacyHeaders: false, handler: (req, res, _next, options) => { - res.setHeader("Retry-After", Math.ceil(options.windowMs / 1000)); + res.setHeader('Retry-After', Math.ceil(options.windowMs / 1000)); res.status(429).json(options.message); }, }); @@ -49,15 +49,15 @@ export const loginRateLimiter = rateLimit({ export const ipLoginRateLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 5, - keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"), + keyGenerator: (req) => ipKeyGenerator(req.ip ?? 'unknown'), message: { success: false, - message: "Too many login attempts from this IP, please try again later.", + message: 'Too many login attempts from this IP, please try again later.', }, standardHeaders: true, legacyHeaders: false, handler: (req, res, _next, options) => { - res.setHeader("Retry-After", Math.ceil(options.windowMs / 1000)); + res.setHeader('Retry-After', Math.ceil(options.windowMs / 1000)); res.status(429).json(options.message); }, }); @@ -65,12 +65,12 @@ export const ipLoginRateLimiter = rateLimit({ export const verifyRateLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 10, - keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"), - message: { success: false, message: "Too many verification attempts" }, + keyGenerator: (req) => ipKeyGenerator(req.ip ?? 'unknown'), + message: { success: false, message: 'Too many verification attempts' }, standardHeaders: true, legacyHeaders: false, handler: (req, res, _next, options) => { - res.setHeader("Retry-After", Math.ceil(options.windowMs / 1000)); + res.setHeader('Retry-After', Math.ceil(options.windowMs / 1000)); res.status(429).json(options.message); }, }); @@ -82,17 +82,17 @@ export const simulationRateLimiter = rateLimit({ keyGenerator: (req) => { // Use authenticated user's public key if available, otherwise fall back to IP const user = (req as unknown as { user?: { publicKey: string } }).user; - return user?.publicKey ?? ipKeyGenerator(req.ip ?? "unknown"); + return user?.publicKey ?? ipKeyGenerator(req.ip ?? 'unknown'); }, message: { success: false, - message: "Too many simulation requests, please try again later.", + message: 'Too many simulation requests, please try again later.', }, standardHeaders: true, legacyHeaders: false, - skip: () => process.env.NODE_ENV === "test", + skip: () => process.env.NODE_ENV === 'test', handler: (req, res, _next, options) => { - res.setHeader("Retry-After", Math.ceil(options.windowMs / 1000)); + res.setHeader('Retry-After', Math.ceil(options.windowMs / 1000)); res.status(429).json(options.message); }, }); diff --git a/backend/src/middleware/requestId.ts b/backend/src/middleware/requestId.ts index f39fd692..308d9cfa 100644 --- a/backend/src/middleware/requestId.ts +++ b/backend/src/middleware/requestId.ts @@ -1,28 +1,21 @@ -import type { Request, Response, NextFunction } from "express"; -import { - createRequestId, - runWithRequestContext, -} from "../utils/requestContext.js"; +import type { Request, Response, NextFunction } from 'express'; +import { createRequestId, runWithRequestContext } from '../utils/requestContext.js'; -declare module "express" { +declare module 'express' { interface Request { requestId?: string; } } -export const requestIdMiddleware = ( - req: Request, - res: Response, - next: NextFunction, -): void => { - const incomingHeader = req.header("x-request-id"); +export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const incomingHeader = req.header('x-request-id'); const requestId = - typeof incomingHeader === "string" && incomingHeader.trim().length > 0 + typeof incomingHeader === 'string' && incomingHeader.trim().length > 0 ? incomingHeader.trim() : createRequestId(); req.requestId = requestId; - res.setHeader("x-request-id", requestId); + res.setHeader('x-request-id', requestId); runWithRequestContext(requestId, () => { next(); diff --git a/backend/src/middleware/requestLogger.ts b/backend/src/middleware/requestLogger.ts index 22f7fe8f..4025f71c 100644 --- a/backend/src/middleware/requestLogger.ts +++ b/backend/src/middleware/requestLogger.ts @@ -1,21 +1,17 @@ -import type { Request, Response, NextFunction } from "express"; -import logger from "../utils/logger.js"; +import type { Request, Response, NextFunction } from 'express'; +import logger from '../utils/logger.js'; /** * Middleware to log HTTP requests with structured fields for parsing and querying. * Logs method, url, statusCode, durationMs, and optional userAgent. */ -export const requestLogger = ( - req: Request, - res: Response, - next: NextFunction, -): void => { +export const requestLogger = (req: Request, res: Response, next: NextFunction): void => { const start = Date.now(); - res.on("finish", () => { + res.on('finish', () => { const durationMs = Date.now() - start; const { method, originalUrl, ip } = req; - const userAgent = req.get("user-agent") ?? undefined; + const userAgent = req.get('user-agent') ?? undefined; const { statusCode } = res; const payload = { @@ -29,11 +25,11 @@ export const requestLogger = ( }; if (statusCode >= 500) { - logger.error("HTTP request", payload); + logger.error('HTTP request', payload); } else if (statusCode >= 400) { - logger.warn("HTTP request", payload); + logger.warn('HTTP request', payload); } else { - logger.http("HTTP request", payload); + logger.http('HTTP request', payload); } }); diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index 9352d211..4db70667 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -1,17 +1,12 @@ -import type { Request, Response, NextFunction } from "express"; -import { z, type ZodSchema, type ZodType } from "zod"; +import type { Request, Response, NextFunction } from 'express'; +import { z, type ZodSchema, type ZodType } from 'zod'; -type ValidationSource = "body" | "query" | "params"; +type ValidationSource = 'body' | 'query' | 'params'; const validateSource = (schema: ZodType, source: ValidationSource) => { return (req: Request, _res: Response, next: NextFunction) => { try { - const data = - source === "body" - ? req.body - : source === "query" - ? req.query - : req.params; + const data = source === 'body' ? req.body : source === 'query' ? req.query : req.params; schema.parse(data); next(); } catch (error) { @@ -20,11 +15,9 @@ const validateSource = (schema: ZodType, source: ValidationSource) => { }; }; -export const validateBody = (schema: ZodType) => validateSource(schema, "body"); -export const validateQuery = (schema: ZodType) => - validateSource(schema, "query"); -export const validateParams = (schema: ZodType) => - validateSource(schema, "params"); +export const validateBody = (schema: ZodType) => validateSource(schema, 'body'); +export const validateQuery = (schema: ZodType) => validateSource(schema, 'query'); +export const validateParams = (schema: ZodType) => validateSource(schema, 'params'); export const validate = (schema: ZodSchema) => { return (req: Request, _res: Response, next: NextFunction) => { diff --git a/backend/src/routes/adminRoutes.ts b/backend/src/routes/adminRoutes.ts index 7f24bba5..4865eb78 100644 --- a/backend/src/routes/adminRoutes.ts +++ b/backend/src/routes/adminRoutes.ts @@ -1,12 +1,12 @@ -import { Router } from "express"; -import { z } from "zod"; -import { requireApiKey } from "../middleware/auth.js"; -import { requireJwtAuth, requireRoles } from "../middleware/jwtAuth.js"; -import { strictRateLimiter } from "../middleware/rateLimiter.js"; -import { validateBody } from "../middleware/validation.js"; -import { asyncHandler } from "../utils/asyncHandler.js"; -import { auditLog } from "../middleware/auditLog.js"; -import { defaultChecker } from "../services/defaultChecker.js"; +import { Router } from 'express'; +import { z } from 'zod'; +import { requireApiKey } from '../middleware/auth.js'; +import { requireJwtAuth, requireRoles } from '../middleware/jwtAuth.js'; +import { strictRateLimiter } from '../middleware/rateLimiter.js'; +import { validateBody } from '../middleware/validation.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; +import { auditLog } from '../middleware/auditLog.js'; +import { defaultChecker } from '../services/defaultChecker.js'; import { createWebhookSubscription, deleteWebhookSubscription, @@ -15,27 +15,27 @@ import { listWebhookSubscriptions, reprocessQuarantinedEvents, reindexLedgerRange, -} from "../controllers/indexerController.js"; +} from '../controllers/indexerController.js'; import { listLoanDisputes, resolveLoanDispute, getLoanDispute, rejectLoanDispute, -} from "../controllers/adminDisputeController.js"; -import { getPendingGovernance } from "../controllers/adminGovernanceController.js"; -import { query } from "../db/connection.js"; +} from '../controllers/adminDisputeController.js'; +import { getPendingGovernance } from '../controllers/adminGovernanceController.js'; +import { query } from '../db/connection.js'; -import { buildRejectLoanTx } from "../controllers/loanController.js"; -import { listAuditLogs } from "../controllers/authController.js"; +import { buildRejectLoanTx } from '../controllers/loanController.js'; +import { listAuditLogs } from '../controllers/authController.js'; const router = Router(); -router.get("/audit-logs", requireJwtAuth, requireRoles("admin"), listAuditLogs); +router.get('/audit-logs', requireJwtAuth, requireRoles('admin'), listAuditLogs); router.post( - "/loans/:loanId/build-reject", + '/loans/:loanId/build-reject', requireJwtAuth, - requireRoles("admin"), + requireRoles('admin'), auditLog, buildRejectLoanTx, ); @@ -89,49 +89,34 @@ router.post( * 400: * description: Validation error */ -router.get("/loan-disputes", requireApiKey("admin:disputes"), listLoanDisputes); +router.get('/loan-disputes', requireApiKey('admin:disputes'), listLoanDisputes); router.post( - "/loan-disputes/:disputeId/resolve", - requireApiKey("admin:disputes"), + '/loan-disputes/:disputeId/resolve', + requireApiKey('admin:disputes'), resolveLoanDispute, ); // New admin JWT-protected endpoints -router.get( - "/disputes", - requireJwtAuth, - requireRoles("admin"), - listLoanDisputes, -); -router.get( - "/disputes/:disputeId", - requireJwtAuth, - requireRoles("admin"), - getLoanDispute, -); +router.get('/disputes', requireJwtAuth, requireRoles('admin'), listLoanDisputes); +router.get('/disputes/:disputeId', requireJwtAuth, requireRoles('admin'), getLoanDispute); router.post( - "/disputes/:disputeId/resolve", + '/disputes/:disputeId/resolve', requireJwtAuth, - requireRoles("admin"), + requireRoles('admin'), resolveLoanDispute, ); router.post( - "/disputes/:disputeId/reject", + '/disputes/:disputeId/reject', requireJwtAuth, - requireRoles("admin"), + requireRoles('admin'), rejectLoanDispute, ); -router.get( - "/governance/pending", - requireJwtAuth, - requireRoles("admin"), - getPendingGovernance, -); +router.get('/governance/pending', requireJwtAuth, requireRoles('admin'), getPendingGovernance); const checkDefaultsBodySchema = z.object({ loanIds: z .array(z.number().int().positive()) - .max(1000, "max 1000 loan IDs per request") + .max(1000, 'max 1000 loan IDs per request') .optional(), }); @@ -171,8 +156,8 @@ const checkDefaultsBodySchema = z.object({ * description: Validation error or too many IDs */ router.post( - "/check-defaults", - requireApiKey("admin:loans"), + '/check-defaults', + requireApiKey('admin:loans'), strictRateLimiter, auditLog, validateBody(checkDefaultsBodySchema), @@ -210,8 +195,8 @@ router.post( * $ref: '#/components/schemas/ReindexResponse' */ router.post( - "/reindex", - requireApiKey("admin:indexer"), + '/reindex', + requireApiKey('admin:indexer'), strictRateLimiter, auditLog, reindexLedgerRange, @@ -241,11 +226,7 @@ router.post( * 200: * description: Quarantined events retrieved */ -router.get( - "/quarantine-events", - requireApiKey("admin:indexer"), - listQuarantinedEvents, -); +router.get('/quarantine-events', requireApiKey('admin:indexer'), listQuarantinedEvents); /** * @swagger @@ -274,8 +255,8 @@ router.get( * description: Reprocess attempt completed */ router.post( - "/quarantine-events/reprocess", - requireApiKey("admin:indexer"), + '/quarantine-events/reprocess', + requireApiKey('admin:indexer'), strictRateLimiter, auditLog, reprocessQuarantinedEvents, @@ -314,8 +295,8 @@ router.post( * $ref: '#/components/schemas/WebhookSubscriptionResponse' */ router.post( - "/webhooks", - requireApiKey("admin:webhooks"), + '/webhooks', + requireApiKey('admin:webhooks'), strictRateLimiter, auditLog, createWebhookSubscription, @@ -337,11 +318,7 @@ router.post( * schema: * $ref: '#/components/schemas/WebhookSubscriptionListResponse' */ -router.get( - "/webhooks", - requireApiKey("admin:webhooks"), - listWebhookSubscriptions, -); +router.get('/webhooks', requireApiKey('admin:webhooks'), listWebhookSubscriptions); /** * @swagger @@ -366,8 +343,8 @@ router.get( * $ref: '#/components/schemas/SuccessMessageResponse' */ router.delete( - "/webhooks/:id", - requireApiKey("admin:webhooks"), + '/webhooks/:id', + requireApiKey('admin:webhooks'), strictRateLimiter, auditLog, deleteWebhookSubscription, @@ -401,11 +378,7 @@ router.delete( * schema: * $ref: '#/components/schemas/WebhookDeliveriesResponse' */ -router.get( - "/webhooks/:id/deliveries", - requireApiKey("admin:webhooks"), - getWebhookDeliveries, -); +router.get('/webhooks/:id/deliveries', requireApiKey('admin:webhooks'), getWebhookDeliveries); /** * @swagger @@ -420,8 +393,8 @@ router.get( * description: Retry status information */ router.get( - "/webhooks/retry-status", - requireApiKey("admin:webhooks"), + '/webhooks/retry-status', + requireApiKey('admin:webhooks'), asyncHandler(async (req, res) => { const result = await query(` SELECT diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index ea02b0ae..9e903987 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -11,25 +11,25 @@ import { challengeRateLimiter, loginRateLimiter, ipLoginRateLimiter, -} from "../middleware/rateLimiter.js"; -import { requireJwtAuth } from "../middleware/jwtAuth.js"; -import { validateBody } from "../middleware/validation.js"; +} from '../middleware/rateLimiter.js'; +import { requireJwtAuth } from '../middleware/jwtAuth.js'; +import { validateBody } from '../middleware/validation.js'; const router = Router(); // TEST/DEV ONLY: Register a test user -if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") { - router.post("/register", registerTestUser); +if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') { + router.post('/register', registerTestUser); } const challengeSchema = z.object({ - publicKey: z.string().min(1, "Public key is required"), + publicKey: z.string().min(1, 'Public key is required'), }); const loginSchema = z.object({ - publicKey: z.string().min(1, "Public key is required"), - message: z.string().min(1, "Message is required"), - signature: z.string().min(1, "Signature is required"), + publicKey: z.string().min(1, 'Public key is required'), + message: z.string().min(1, 'Message is required'), + signature: z.string().min(1, 'Signature is required'), }); /** @@ -58,12 +58,7 @@ const loginSchema = z.object({ * schema: * $ref: '#/components/schemas/AuthChallengeResponse' */ -router.post( - "/challenge", - challengeRateLimiter, - validateBody(challengeSchema), - requestChallenge, -); +router.post('/challenge', challengeRateLimiter, validateBody(challengeSchema), requestChallenge); /** * @swagger @@ -95,13 +90,7 @@ router.post( * schema: * $ref: '#/components/schemas/AuthLoginResponse' */ -router.post( - "/login", - ipLoginRateLimiter, - loginRateLimiter, - validateBody(loginSchema), - login, -); +router.post('/login', ipLoginRateLimiter, loginRateLimiter, validateBody(loginSchema), login); /** * @swagger @@ -121,7 +110,7 @@ router.post( * 401: * description: Missing or invalid Bearer token */ -router.get("/verify", requireJwtAuth, verify); +router.get('/verify', requireJwtAuth, verify); /** * @swagger diff --git a/backend/src/routes/eventRoutes.ts b/backend/src/routes/eventRoutes.ts index 53ecf8be..7dfb1aee 100644 --- a/backend/src/routes/eventRoutes.ts +++ b/backend/src/routes/eventRoutes.ts @@ -1,10 +1,7 @@ -import { Router } from "express"; -import { - streamEvents, - getEventStreamStatus, -} from "../controllers/eventStreamController.js"; -import { requireJwtAuth } from "../middleware/jwtAuth.js"; -import { requireApiKey } from "../middleware/auth.js"; +import { Router } from 'express'; +import { streamEvents, getEventStreamStatus } from '../controllers/eventStreamController.js'; +import { requireJwtAuth } from '../middleware/jwtAuth.js'; +import { requireApiKey } from '../middleware/auth.js'; const router = Router(); @@ -51,7 +48,7 @@ const router = Router(); * 401: * description: Missing or invalid authentication */ -router.get("/stream", requireJwtAuth, streamEvents); +router.get('/stream', requireJwtAuth, streamEvents); /** * @swagger @@ -74,6 +71,6 @@ router.get("/stream", requireJwtAuth, streamEvents); * 401: * description: Missing or invalid API key */ -router.get("/status", requireApiKey("admin:indexer"), getEventStreamStatus); +router.get('/status', requireApiKey('admin:indexer'), getEventStreamStatus); export default router; diff --git a/backend/src/routes/indexerRoutes.ts b/backend/src/routes/indexerRoutes.ts index 865b1df9..33676449 100644 --- a/backend/src/routes/indexerRoutes.ts +++ b/backend/src/routes/indexerRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router } from 'express'; import { getIndexerStatus, getBorrowerEvents, @@ -7,16 +7,12 @@ import { listWebhookSubscriptions, createWebhookSubscription, deleteWebhookSubscription, -} from "../controllers/indexerController.js"; -import { requireApiKey } from "../middleware/auth.js"; -import { - requireJwtAuth, - requireScopes, - requireWalletOwnership, -} from "../middleware/jwtAuth.js"; -import { requireLoanBorrowerAccess } from "../middleware/loanAccess.js"; -import { strictRateLimiter } from "../middleware/rateLimiter.js"; -import { auditLog } from "../middleware/auditLog.js"; +} from '../controllers/indexerController.js'; +import { requireApiKey } from '../middleware/auth.js'; +import { requireJwtAuth, requireScopes, requireWalletOwnership } from '../middleware/jwtAuth.js'; +import { requireLoanBorrowerAccess } from '../middleware/loanAccess.js'; +import { strictRateLimiter } from '../middleware/rateLimiter.js'; +import { auditLog } from '../middleware/auditLog.js'; const router = Router(); @@ -35,7 +31,7 @@ const router = Router(); * schema: * $ref: '#/components/schemas/IndexerStatusResponse' */ -router.get("/status", getIndexerStatus); +router.get('/status', getIndexerStatus); /** * @swagger @@ -78,9 +74,9 @@ router.get("/status", getIndexerStatus); * description: borrower does not match authenticated wallet */ router.get( - "/events/borrower/:borrower", + '/events/borrower/:borrower', requireJwtAuth, - requireScopes("read:loans"), + requireScopes('read:loans'), requireWalletOwnership, getBorrowerEvents, ); @@ -115,9 +111,9 @@ router.get( * description: Loan not found or not accessible */ router.get( - "/events/loan/:loanId", + '/events/loan/:loanId', requireJwtAuth, - requireScopes("read:loans"), + requireScopes('read:loans'), requireLoanBorrowerAccess, getLoanEvents, ); @@ -154,7 +150,7 @@ router.get( * 401: * description: Missing or invalid API key */ -router.get("/events/recent", requireApiKey("admin:indexer"), getRecentEvents); +router.get('/events/recent', requireApiKey('admin:indexer'), getRecentEvents); /** * @swagger @@ -174,11 +170,7 @@ router.get("/events/recent", requireApiKey("admin:indexer"), getRecentEvents); * 401: * description: Missing or invalid API key */ -router.get( - "/webhooks", - requireApiKey("admin:webhooks"), - listWebhookSubscriptions, -); +router.get('/webhooks', requireApiKey('admin:webhooks'), listWebhookSubscriptions); /** * @swagger @@ -216,8 +208,8 @@ router.get( * description: Missing or invalid API key */ router.post( - "/webhooks", - requireApiKey("admin:webhooks"), + '/webhooks', + requireApiKey('admin:webhooks'), strictRateLimiter, auditLog, createWebhookSubscription, @@ -248,8 +240,8 @@ router.post( * description: Missing or invalid API key */ router.delete( - "/webhooks/:subscriptionId", - requireApiKey("admin:webhooks"), + '/webhooks/:subscriptionId', + requireApiKey('admin:webhooks'), strictRateLimiter, auditLog, deleteWebhookSubscription, diff --git a/backend/src/routes/loanRoutes.ts b/backend/src/routes/loanRoutes.ts index 5722401b..bc33a2ad 100644 --- a/backend/src/routes/loanRoutes.ts +++ b/backend/src/routes/loanRoutes.ts @@ -1,7 +1,7 @@ -import { createTestLoan } from "../controllers/loanController.js"; -import { markLoanDefaulted } from "../controllers/loanController.js"; -import { contestDefault } from "../controllers/loanController.js"; -import { Router } from "express"; +import { createTestLoan } from '../controllers/loanController.js'; +import { markLoanDefaulted } from '../controllers/loanController.js'; +import { contestDefault } from '../controllers/loanController.js'; +import { Router } from 'express'; import { getLoanConfigEndpoint, getBorrowerLoans, @@ -16,25 +16,13 @@ import { extendLoan, buildLiquidateLoan, submitTransaction, -} from "../controllers/loanController.js"; -import { getLoanEvents } from "../controllers/indexerController.js"; -import { - requireJwtAuth, - requireScopes, - requireWalletOwnership, -} from "../middleware/jwtAuth.js"; -import { - requireLoanBorrowerAccess, - requireLoanOwner, -} from "../middleware/loanAccess.js"; -import { - validate, - validateBody, - validateParams, - validateQuery, -} from "../middleware/validation.js"; -import { idempotencyMiddleware } from "../middleware/idempotency.js"; -import { borrowerParamSchema } from "../schemas/stellarSchemas.js"; +} from '../controllers/loanController.js'; +import { getLoanEvents } from '../controllers/indexerController.js'; +import { requireJwtAuth, requireScopes, requireWalletOwnership } from '../middleware/jwtAuth.js'; +import { requireLoanBorrowerAccess, requireLoanOwner } from '../middleware/loanAccess.js'; +import { validate, validateBody, validateParams, validateQuery } from '../middleware/validation.js'; +import { idempotencyMiddleware } from '../middleware/idempotency.js'; +import { borrowerParamSchema } from '../schemas/stellarSchemas.js'; import { previewAmortizationSchema, requestLoanSchema, @@ -47,28 +35,23 @@ import { extendLoanSchema, liquidateLoanSchema, borrowerLoansQuerySchema, -} from "../schemas/loanSchemas.js"; +} from '../schemas/loanSchemas.js'; -import { buildCancelLoanTx } from "../controllers/loanController.js"; +import { buildCancelLoanTx } from '../controllers/loanController.js'; const router = Router(); // TEST/DEV ONLY: Create a loan directly for test setup -if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") { - router.post("/", requireJwtAuth, createTestLoan); +if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') { + router.post('/', requireJwtAuth, createTestLoan); } // TEST/DEV ONLY: Mark a loan as defaulted for test setup -if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") { - router.post( - "/:loanId/mark-defaulted", - requireJwtAuth, - requireLoanOwner, - markLoanDefaulted, - ); +if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') { + router.post('/:loanId/mark-defaulted', requireJwtAuth, requireLoanOwner, markLoanDefaulted); } -router.get("/config", getLoanConfigEndpoint); +router.get('/config', getLoanConfigEndpoint); /** * @swagger @@ -117,15 +100,10 @@ router.get("/config", getLoanConfigEndpoint); * description: Reject transaction built */ -router.post( - "/:loanId/build-cancel", - requireJwtAuth, - requireLoanOwner, - buildCancelLoanTx, -); +router.post('/:loanId/build-cancel', requireJwtAuth, requireLoanOwner, buildCancelLoanTx); router.post( - "/amortization-preview", + '/amortization-preview', requireJwtAuth, validateBody(previewAmortizationSchema), previewLoanAmortizationSchedule, @@ -172,12 +150,7 @@ router.post( * 404: * description: Loan not found */ -router.post( - "/:loanId/contest-default", - requireJwtAuth, - requireLoanOwner, - contestDefault, -); +router.post('/:loanId/contest-default', requireJwtAuth, requireLoanOwner, contestDefault); /** * @swagger @@ -244,9 +217,9 @@ router.post( * description: borrower does not match authenticated wallet */ router.get( - "/borrower/:borrower", + '/borrower/:borrower', requireJwtAuth, - requireScopes("read:loans"), + requireScopes('read:loans'), requireWalletOwnership, validate(borrowerParamSchema), validateQuery(borrowerLoansQuerySchema), @@ -286,17 +259,17 @@ router.get( * description: Loan not found */ router.get( - "/:loanId", + '/:loanId', requireJwtAuth, - requireScopes("read:loans"), + requireScopes('read:loans'), requireLoanBorrowerAccess, getLoanDetails, ); router.get( - "/:loanId/amortization-schedule", + '/:loanId/amortization-schedule', requireJwtAuth, - requireScopes("read:loans"), + requireScopes('read:loans'), requireLoanBorrowerAccess, getLoanAmortizationSchedule, ); @@ -336,9 +309,9 @@ router.get( * description: Loan not found or not accessible */ router.get( - "/:loanId/events", + '/:loanId/events', requireJwtAuth, - requireScopes("read:loans"), + requireScopes('read:loans'), requireLoanBorrowerAccess, getLoanEvents, ); @@ -384,7 +357,7 @@ router.get( * description: Missing or invalid Bearer token */ router.post( - "/request", + '/request', requireJwtAuth, requireScopes("write:loans"), validateBody(requestLoanSchema), @@ -437,7 +410,7 @@ router.post( * description: Loan belongs to a different borrower */ router.post( - "/:loanId/build-deposit-collateral", + '/:loanId/build-deposit-collateral', requireJwtAuth, requireScopes("write:loans"), requireLoanOwner, @@ -488,7 +461,7 @@ router.post( * description: Loan belongs to a different borrower */ router.post( - "/:loanId/build-release-collateral", + '/:loanId/build-release-collateral', requireJwtAuth, requireScopes("write:loans"), requireLoanOwner, @@ -547,7 +520,7 @@ router.post( * description: Loan belongs to a different borrower */ router.post( - "/:loanId/build-refinance", + '/:loanId/build-refinance', requireJwtAuth, requireScopes("write:loans"), requireLoanOwner, @@ -602,7 +575,7 @@ router.post( * description: Loan belongs to a different borrower */ router.post( - "/:loanId/build-extend", + '/:loanId/build-extend', requireJwtAuth, requireScopes("write:loans"), requireLoanOwner, @@ -653,7 +626,7 @@ router.post( * description: liquidatorPublicKey does not match authenticated wallet */ router.post( - "/:loanId/liquidate/build", + '/:loanId/liquidate/build', requireJwtAuth, requireScopes("write:loans"), validateParams(repayLoanParamsSchema), @@ -697,7 +670,7 @@ router.post( * description: Missing or invalid Bearer token */ router.post( - "/submit", + '/submit', requireJwtAuth, requireScopes("write:loans"), validateBody(submitTxSchema), @@ -758,7 +731,7 @@ router.post( * description: Loan not found */ router.post( - "/:loanId/repay", + '/:loanId/repay', requireJwtAuth, requireScopes("write:loans"), requireLoanOwner, @@ -814,7 +787,7 @@ router.post( * description: Loan not found */ router.post( - "/:loanId/submit", + '/:loanId/submit', requireJwtAuth, requireScopes("write:loans"), requireLoanOwner, diff --git a/backend/src/routes/notificationsRoutes.ts b/backend/src/routes/notificationsRoutes.ts index 99bb1f7e..ae8bb663 100644 --- a/backend/src/routes/notificationsRoutes.ts +++ b/backend/src/routes/notificationsRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router } from 'express'; import { getNotifications, getNotificationPreferences, @@ -6,8 +6,8 @@ import { markAllRead, streamNotifications, updateNotificationPreferences, -} from "../controllers/notificationController.js"; -import { requireJwtAuth, requireScopes } from "../middleware/jwtAuth.js"; +} from '../controllers/notificationController.js'; +import { requireJwtAuth, requireScopes } from '../middleware/jwtAuth.js'; const router = Router(); @@ -61,12 +61,7 @@ const router = Router(); * schema: * $ref: '#/components/schemas/NotificationsResponse' */ -router.get( - "/", - requireJwtAuth, - requireScopes("read:notifications"), - getNotifications, -); +router.get('/', requireJwtAuth, requireScopes('read:notifications'), getNotifications); /** * @swagger @@ -84,7 +79,7 @@ router.get( * schema: * $ref: '#/components/schemas/NotificationPreferences' */ -router.get("/preferences", requireJwtAuth, getNotificationPreferences); +router.get('/preferences', requireJwtAuth, getNotificationPreferences); /** * @swagger @@ -108,7 +103,7 @@ router.get("/preferences", requireJwtAuth, getNotificationPreferences); * schema: * $ref: '#/components/schemas/NotificationPreferences' */ -router.put("/preferences", requireJwtAuth, updateNotificationPreferences); +router.put('/preferences', requireJwtAuth, updateNotificationPreferences); /** * @swagger @@ -130,12 +125,7 @@ router.put("/preferences", requireJwtAuth, updateNotificationPreferences); * schema: * $ref: '#/components/schemas/ServerSentEventStream' */ -router.get( - "/stream", - requireJwtAuth, - requireScopes("read:notifications"), - streamNotifications, -); +router.get('/stream', requireJwtAuth, requireScopes('read:notifications'), streamNotifications); /** * @swagger @@ -164,12 +154,7 @@ router.get( * schema: * $ref: '#/components/schemas/SimpleSuccessResponse' */ -router.post( - "/mark-read", - requireJwtAuth, - requireScopes("write:notifications"), - markRead, -); +router.post('/mark-read', requireJwtAuth, requireScopes('write:notifications'), markRead); /** * @swagger @@ -187,11 +172,6 @@ router.post( * schema: * $ref: '#/components/schemas/SimpleSuccessResponse' */ -router.post( - "/mark-all-read", - requireJwtAuth, - requireScopes("write:notifications"), - markAllRead, -); +router.post('/mark-all-read', requireJwtAuth, requireScopes('write:notifications'), markAllRead); export default router; diff --git a/backend/src/routes/poolRoutes.ts b/backend/src/routes/poolRoutes.ts index 98e7624b..642a61f9 100644 --- a/backend/src/routes/poolRoutes.ts +++ b/backend/src/routes/poolRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router } from 'express'; import { getPoolStats, getDepositorPortfolio, @@ -8,22 +8,22 @@ import { emergencyWithdrawFromPool, getPoolSharePrice, submitPoolTransaction, -} from "../controllers/poolController.js"; +} from '../controllers/poolController.js'; import { requireLender, requireJwtAuth, requireScopes, requireWalletParamMatchesJwt, -} from "../middleware/jwtAuth.js"; -import { validate, validateBody } from "../middleware/validation.js"; -import { idempotencyMiddleware } from "../middleware/idempotency.js"; -import { addressParamSchema } from "../schemas/stellarSchemas.js"; +} from '../middleware/jwtAuth.js'; +import { validate, validateBody } from '../middleware/validation.js'; +import { idempotencyMiddleware } from '../middleware/idempotency.js'; +import { addressParamSchema } from '../schemas/stellarSchemas.js'; import { buildPoolTransactionSchema, emergencyWithdrawSchema, getDepositorYieldHistorySchema, submitTxSchema, -} from "../schemas/poolSchemas.js"; +} from '../schemas/poolSchemas.js'; const router = Router(); @@ -48,13 +48,7 @@ const router = Router(); * 401: * description: Missing or invalid Bearer token */ -router.get( - "/stats", - requireJwtAuth, - requireLender, - requireScopes("read:pool"), - getPoolStats, -); +router.get('/stats', requireJwtAuth, requireLender, requireScopes('read:pool'), getPoolStats); /** * @swagger @@ -87,11 +81,11 @@ router.get( * description: address does not match authenticated wallet */ router.get( - "/depositor/:address", + '/depositor/:address', requireJwtAuth, requireLender, - requireScopes("read:pool"), - requireWalletParamMatchesJwt("address"), + requireScopes('read:pool'), + requireWalletParamMatchesJwt('address'), validate(addressParamSchema), getDepositorPortfolio, ); @@ -133,11 +127,11 @@ router.get( * description: address does not match authenticated wallet */ router.get( - "/depositor/:address/yield-history", + '/depositor/:address/yield-history', requireJwtAuth, requireLender, - requireScopes("read:pool"), - requireWalletParamMatchesJwt("address"), + requireScopes('read:pool'), + requireWalletParamMatchesJwt('address'), validate(getDepositorYieldHistorySchema), getDepositorYieldHistory, ); @@ -168,10 +162,10 @@ router.get( * description: Missing or invalid Bearer token */ router.get( - "/:token/share-price", + '/:token/share-price', requireJwtAuth, requireLender, - requireScopes("read:pool"), + requireScopes('read:pool'), getPoolSharePrice, ); @@ -221,10 +215,10 @@ router.get( * description: Missing or invalid Bearer token */ router.post( - "/build-deposit", + '/build-deposit', requireJwtAuth, requireLender, - requireScopes("write:pool"), + requireScopes('write:pool'), validateBody(buildPoolTransactionSchema), idempotencyMiddleware, depositToPool, @@ -276,10 +270,10 @@ router.post( * description: Missing or invalid Bearer token */ router.post( - "/build-withdraw", + '/build-withdraw', requireJwtAuth, requireLender, - requireScopes("write:pool"), + requireScopes('write:pool'), validateBody(buildPoolTransactionSchema), idempotencyMiddleware, withdrawFromPool, @@ -327,10 +321,10 @@ router.post( * description: Missing or invalid Bearer token */ router.post( - "/build-emergency-withdraw", + '/build-emergency-withdraw', requireJwtAuth, requireLender, - requireScopes("write:pool"), + requireScopes('write:pool'), validateBody(emergencyWithdrawSchema), idempotencyMiddleware, emergencyWithdrawFromPool, @@ -372,10 +366,10 @@ router.post( * description: Missing or invalid Bearer token */ router.post( - "/submit", + '/submit', requireJwtAuth, requireLender, - requireScopes("write:pool"), + requireScopes('write:pool'), validateBody(submitTxSchema), idempotencyMiddleware, submitPoolTransaction, diff --git a/backend/src/routes/remittanceRoutes.ts b/backend/src/routes/remittanceRoutes.ts index 50dc321b..9eb57b22 100644 --- a/backend/src/routes/remittanceRoutes.ts +++ b/backend/src/routes/remittanceRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router } from 'express'; import { createRemittance, getRemittances, @@ -12,7 +12,7 @@ import { createRemittanceSchema, getRemittancesSchema, getRemittanceSchema, -} from "../schemas/remittanceSchemas.js"; +} from '../schemas/remittanceSchemas.js'; const router = Router(); @@ -72,9 +72,9 @@ const router = Router(); * description: Missing or invalid Bearer token */ router.post( - "/", + '/', requireJwtAuth, - requireScopes("write:remittances"), + requireScopes('write:remittances'), validate(createRemittanceSchema), idempotencyMiddleware, createRemittance, @@ -134,9 +134,9 @@ router.post( * description: Missing or invalid Bearer token */ router.get( - "/", + '/', requireJwtAuth, - requireScopes("read:remittances"), + requireScopes('read:remittances'), validate(getRemittancesSchema), getRemittances, ); @@ -179,9 +179,9 @@ router.get( * description: Remittance not found */ router.get( - "/:id", + '/:id', requireJwtAuth, - requireScopes("read:remittances"), + requireScopes('read:remittances'), validate(getRemittanceSchema), getRemittance, ); @@ -229,7 +229,7 @@ router.get( * description: Remittance not found */ router.post( - "/:id/submit", + '/:id/submit', requireJwtAuth, requireScopes("write:remittances"), idempotencyMiddleware, diff --git a/backend/src/routes/scoreRoutes.ts b/backend/src/routes/scoreRoutes.ts index 0608d88c..ea6c72aa 100644 --- a/backend/src/routes/scoreRoutes.ts +++ b/backend/src/routes/scoreRoutes.ts @@ -1,25 +1,25 @@ -import { Router } from "express"; +import { Router } from 'express'; import { getScore, updateScore, getScoreBreakdown, getOnChainScoreHistory, getRemittanceNft, -} from "../controllers/scoreController.js"; -import { validate } from "../middleware/validation.js"; +} from '../controllers/scoreController.js'; +import { validate } from '../middleware/validation.js'; import { getRemittanceNftSchema, getScoreHistorySchema, getScoreSchema, updateScoreSchema, -} from "../schemas/scoreSchemas.js"; -import { requireApiKey } from "../middleware/auth.js"; -import { scoreUpdateRateLimit } from "../middleware/rateLimitMiddleware.js"; +} from '../schemas/scoreSchemas.js'; +import { requireApiKey } from '../middleware/auth.js'; +import { scoreUpdateRateLimit } from '../middleware/rateLimitMiddleware.js'; import { requireJwtAuth, requireScopes, requireWalletParamMatchesJwt, -} from "../middleware/jwtAuth.js"; +} from '../middleware/jwtAuth.js'; const router = Router(); @@ -60,10 +60,10 @@ const router = Router(); * description: userId does not match the authenticated wallet. */ router.get( - "/:userId", + '/:userId', requireJwtAuth, - requireScopes("read:score"), - requireWalletParamMatchesJwt("userId"), + requireScopes('read:score'), + requireWalletParamMatchesJwt('userId'), validate(getScoreSchema), getScore, ); @@ -96,19 +96,19 @@ router.get( * description: walletAddress does not match the authenticated wallet. */ router.get( - "/:walletAddress/history", + '/:walletAddress/history', requireJwtAuth, - requireScopes("read:score"), - requireWalletParamMatchesJwt("walletAddress"), + requireScopes('read:score'), + requireWalletParamMatchesJwt('walletAddress'), validate(getScoreHistorySchema), getOnChainScoreHistory, ); router.get( - "/:walletAddress/nft", + '/:walletAddress/nft', requireJwtAuth, - requireScopes("read:score"), - requireWalletParamMatchesJwt("walletAddress"), + requireScopes('read:score'), + requireWalletParamMatchesJwt('walletAddress'), validate(getRemittanceNftSchema), getRemittanceNft, ); @@ -146,10 +146,10 @@ router.get( * description: userId does not match the authenticated wallet. */ router.get( - "/:userId/breakdown", + '/:userId/breakdown', requireJwtAuth, - requireScopes("read:score"), - requireWalletParamMatchesJwt("userId"), + requireScopes('read:score'), + requireWalletParamMatchesJwt('userId'), validate(getScoreSchema), getScoreBreakdown, ); @@ -206,8 +206,8 @@ router.get( * $ref: '#/components/schemas/ErrorResponse' */ router.post( - "/update", - requireApiKey("admin:loans"), + '/update', + requireApiKey('admin:loans'), scoreUpdateRateLimit, validate(updateScoreSchema), updateScore, diff --git a/backend/src/routes/simulationRoutes.ts b/backend/src/routes/simulationRoutes.ts index 1473e3cd..8b311b76 100644 --- a/backend/src/routes/simulationRoutes.ts +++ b/backend/src/routes/simulationRoutes.ts @@ -59,7 +59,7 @@ const router = Router(); */ router.get( - "/history/:userId", + '/history/:userId', simulationRateLimiter, requireJwtAuth, requireWalletParamMatchesJwt("userId"), diff --git a/backend/src/routes/transactionRoutes.ts b/backend/src/routes/transactionRoutes.ts index 954d168c..be6b9641 100644 --- a/backend/src/routes/transactionRoutes.ts +++ b/backend/src/routes/transactionRoutes.ts @@ -1,9 +1,9 @@ -import { Router } from "express"; -import { listMyTransactions } from "../controllers/transactionController.js"; -import { requireJwtAuth } from "../middleware/jwtAuth.js"; +import { Router } from 'express'; +import { listMyTransactions } from '../controllers/transactionController.js'; +import { requireJwtAuth } from '../middleware/jwtAuth.js'; const router = Router(); -router.get("/me", requireJwtAuth, listMyTransactions); +router.get('/me', requireJwtAuth, listMyTransactions); export default router; diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts index fda4c7bc..91c0703e 100644 --- a/backend/src/routes/userRoutes.ts +++ b/backend/src/routes/userRoutes.ts @@ -1,11 +1,8 @@ -import { Router } from "express"; -import { - getUserProfile, - updateUserProfile, -} from "../controllers/userController.js"; -import { requireJwtAuth } from "../middleware/jwtAuth.js"; -import { validateBody } from "../middleware/validation.js"; -import { updateUserProfileSchema } from "../schemas/userSchemas.js"; +import { Router } from 'express'; +import { getUserProfile, updateUserProfile } from '../controllers/userController.js'; +import { requireJwtAuth } from '../middleware/jwtAuth.js'; +import { validateBody } from '../middleware/validation.js'; +import { updateUserProfileSchema } from '../schemas/userSchemas.js'; const router = Router(); @@ -23,7 +20,7 @@ const router = Router(); * 401: * description: Missing or invalid JWT */ -router.get("/profile", requireJwtAuth, getUserProfile); +router.get('/profile', requireJwtAuth, getUserProfile); /** * @swagger @@ -67,11 +64,6 @@ router.get("/profile", requireJwtAuth, getUserProfile); * 401: * description: Missing or invalid JWT */ -router.patch( - "/profile", - requireJwtAuth, - validateBody(updateUserProfileSchema), - updateUserProfile, -); +router.patch('/profile', requireJwtAuth, validateBody(updateUserProfileSchema), updateUserProfile); export default router; diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index 53b8a45f..9de598e6 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -1,4 +1,4 @@ -export * from "./simulationSchemas.js"; -export * from "./scoreSchemas.js"; -export * from "./stellarSchemas.js"; -export * from "./notificationSchemas.js"; +export * from './simulationSchemas.js'; +export * from './scoreSchemas.js'; +export * from './stellarSchemas.js'; +export * from './notificationSchemas.js'; diff --git a/backend/src/schemas/loanSchemas.ts b/backend/src/schemas/loanSchemas.ts index 823ca986..f674a21d 100644 --- a/backend/src/schemas/loanSchemas.ts +++ b/backend/src/schemas/loanSchemas.ts @@ -1,22 +1,18 @@ -import { z } from "zod"; -import { stellarAddressSchema } from "./stellarSchemas.js"; +import { z } from 'zod'; +import { stellarAddressSchema } from './stellarSchemas.js'; export const rejectLoanSchema = z.object({ reason: z .string() - .min(5, "Reason must be at least 5 characters") - .max(500, "Reason cannot exceed 500 characters"), + .min(5, 'Reason must be at least 5 characters') + .max(500, 'Reason cannot exceed 500 characters'), }); export type RejectLoanInput = z.infer; -export const positiveAmountSchema = z - .number() - .int() - .positive("Amount must be a positive integer"); +export const positiveAmountSchema = z.number().int().positive('Amount must be a positive integer'); -const base64Regex = - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; export const requestLoanSchema = z.object({ amount: positiveAmountSchema, @@ -34,17 +30,14 @@ export const previewAmortizationSchema = z.object({ }); export const repayLoanParamsSchema = z.object({ - loanId: z.coerce - .number() - .int() - .positive("Loan ID must be a positive integer"), + loanId: z.coerce.number().int().positive('Loan ID must be a positive integer'), }); export const submitTxSchema = z.object({ signedTxXdr: z .string() - .min(1, "signedTxXdr is required") - .regex(base64Regex, "Must be a valid base64 string"), + .min(1, 'signedTxXdr is required') + .regex(base64Regex, 'Must be a valid base64 string'), }); export const depositCollateralSchema = z.object({ @@ -58,15 +51,12 @@ export const releaseCollateralSchema = z.object({ export const refinanceLoanSchema = z.object({ newAmount: positiveAmountSchema, - newTerm: z.number().int().positive("Term must be a positive integer"), + newTerm: z.number().int().positive('Term must be a positive integer'), borrowerPublicKey: stellarAddressSchema, }); export const extendLoanSchema = z.object({ - extraLedgers: z - .number() - .int() - .positive("Extra ledgers must be a positive integer"), + extraLedgers: z.number().int().positive('Extra ledgers must be a positive integer'), borrowerPublicKey: stellarAddressSchema, }); @@ -82,16 +72,12 @@ export const liquidateLoanSchema = z.object({ * `limit` – page size (default 50, max 100) * `cursor` – opaque cursor (loan_id string from previous response) */ -const isoDateString = z - .string() - .refine((val) => !Number.isNaN(Date.parse(val)), { - message: "Must be a valid ISO-8601 date string", - }); +const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), { + message: 'Must be a valid ISO-8601 date string', +}); export const borrowerLoansQuerySchema = z.object({ - status: z - .enum(["active", "repaid", "defaulted", "liquidated", "pending", "all"]) - .optional(), + status: z.enum(['active', 'repaid', 'defaulted', 'liquidated', 'pending', 'all']).optional(), from: isoDateString.optional(), to: isoDateString.optional(), limit: z.coerce.number().int().min(1).max(100).optional(), diff --git a/backend/src/schemas/notificationSchemas.ts b/backend/src/schemas/notificationSchemas.ts index 32dcfa9f..4360d5ba 100644 --- a/backend/src/schemas/notificationSchemas.ts +++ b/backend/src/schemas/notificationSchemas.ts @@ -1,15 +1,13 @@ -import { z } from "zod"; +import { z } from 'zod'; const e164PhoneRegex = /^\+?[1-9]\d{1,14}$/; const perTypeOverridesSchema = z.record(z.string(), z.boolean()).default({}); // ISO date string validation -const isoDateString = z - .string() - .refine((val) => !Number.isNaN(Date.parse(val)), { - message: "Must be a valid ISO-8601 date string", - }); +const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), { + message: 'Must be a valid ISO-8601 date string', +}); export const updateNotificationPreferencesSchema = z.object({ emailEnabled: z.boolean(), @@ -28,14 +26,14 @@ export const notificationPreferencesResponseSchema = z.object({ export const getNotificationsQuerySchema = z.object({ type: z .enum([ - "loan_approved", - "repayment_due", - "repayment_confirmed", - "loan_defaulted", - "score_changed", + 'loan_approved', + 'repayment_due', + 'repayment_confirmed', + 'loan_defaulted', + 'score_changed', ]) .optional(), - status: z.enum(["unread", "read", "archived"]).optional(), + status: z.enum(['unread', 'read', 'archived']).optional(), from: isoDateString.optional(), to: isoDateString.optional(), limit: z.coerce.number().int().min(1).max(100).optional(), diff --git a/backend/src/schemas/poolSchemas.ts b/backend/src/schemas/poolSchemas.ts index 5d3c5342..c0027fb9 100644 --- a/backend/src/schemas/poolSchemas.ts +++ b/backend/src/schemas/poolSchemas.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; -import { stellarAddressSchema } from "./stellarSchemas.js"; -import { submitTxSchema, positiveAmountSchema } from "./loanSchemas.js"; +import { z } from 'zod'; +import { stellarAddressSchema } from './stellarSchemas.js'; +import { submitTxSchema, positiveAmountSchema } from './loanSchemas.js'; export const buildPoolTransactionSchema = z.object({ depositorPublicKey: stellarAddressSchema, @@ -23,7 +23,7 @@ export const getDepositorYieldHistorySchema = z.object({ .number() .int() .refine((v) => v === 7 || v === 30 || v === 90, { - message: "days must be 7, 30, or 90", + message: 'days must be 7, 30, or 90', }) .optional(), token: stellarAddressSchema.optional(), diff --git a/backend/src/schemas/remittanceSchemas.ts b/backend/src/schemas/remittanceSchemas.ts index 09882e60..a1afedda 100644 --- a/backend/src/schemas/remittanceSchemas.ts +++ b/backend/src/schemas/remittanceSchemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; // Stellar address regex (56 chars, starts with G, base32) const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/; @@ -8,31 +8,27 @@ export const createRemittanceSchema = z.object({ body: z.object({ recipientAddress: z .string() - .regex(STELLAR_ADDRESS_REGEX, "Invalid Stellar address format") + .regex(STELLAR_ADDRESS_REGEX, 'Invalid Stellar address format') .describe("Recipient's Stellar public key"), amount: z .number() - .positive("Amount must be greater than 0") - .max(1_000_000, "Amount exceeds maximum limit") - .describe("Amount to send"), - fromCurrency: z.enum(["USDC", "EURC", "PHP"]).describe("Source currency"), - toCurrency: z - .enum(["USDC", "EURC", "PHP"]) - .describe("Destination currency"), + .positive('Amount must be greater than 0') + .max(1_000_000, 'Amount exceeds maximum limit') + .describe('Amount to send'), + fromCurrency: z.enum(['USDC', 'EURC', 'PHP']).describe('Source currency'), + toCurrency: z.enum(['USDC', 'EURC', 'PHP']).describe('Destination currency'), memo: z .string() - .max(28, "Memo must be 28 characters or less") + .max(28, 'Memo must be 28 characters or less') .optional() - .describe("Optional transaction memo"), + .describe('Optional transaction memo'), }), }); // ISO date string validation -const isoDateString = z - .string() - .refine((val) => !Number.isNaN(Date.parse(val)), { - message: "Must be a valid ISO-8601 date string", - }); +const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), { + message: 'Must be a valid ISO-8601 date string', +}); // Schema for GET /remittances (list) export const getRemittancesSchema = z.object({ @@ -44,7 +40,7 @@ export const getRemittancesSchema = z.object({ .default(20) .optional(), cursor: z.string().optional(), - status: z.enum(["pending", "processing", "completed", "failed"]).optional(), + status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(), from: isoDateString.optional(), to: isoDateString.optional(), q: z.string().max(255).optional(), @@ -54,10 +50,7 @@ export const getRemittancesSchema = z.object({ // Schema for GET /remittances/:id export const getRemittanceSchema = z.object({ params: z.object({ - id: z - .string() - .min(1, "Remittance ID is required") - .describe("Remittance ID (UUID format)"), + id: z.string().min(1, 'Remittance ID is required').describe('Remittance ID (UUID format)'), }), }); diff --git a/backend/src/schemas/scoreSchemas.ts b/backend/src/schemas/scoreSchemas.ts index c6189d17..50a03c31 100644 --- a/backend/src/schemas/scoreSchemas.ts +++ b/backend/src/schemas/scoreSchemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; // Schema for GET /score/:userId export const getScoreSchema = z.object({ @@ -27,10 +27,10 @@ export const updateScoreSchema = z.object({ userId: z.string().min(1).max(100), repaymentAmount: z .number() - .positive("Repayment amount must be positive") - .max(1_000_000, "Repayment amount exceeds maximum limit"), + .positive('Repayment amount must be positive') + .max(1_000_000, 'Repayment amount exceeds maximum limit'), onTime: z.boolean({ - message: "onTime must be a boolean", + message: 'onTime must be a boolean', }), }), }); diff --git a/backend/src/schemas/simulationSchemas.ts b/backend/src/schemas/simulationSchemas.ts index 9bb669d2..dc1103fe 100644 --- a/backend/src/schemas/simulationSchemas.ts +++ b/backend/src/schemas/simulationSchemas.ts @@ -1,12 +1,9 @@ -import { z } from "zod"; +import { z } from 'zod'; // Schema for GET /history/:userId export const getRemittanceHistorySchema = z.object({ params: z.object({ - userId: z - .string() - .min(1, "User ID is required") - .max(100, "User ID is too long"), + userId: z.string().min(1, 'User ID is required').max(100, 'User ID is too long'), }), }); @@ -15,13 +12,11 @@ export const simulatePaymentSchema = z.object({ body: z.object({ amount: z .number() - .positive("Amount must be positive") - .max(1000000, "Amount exceeds maximum limit"), + .positive('Amount must be positive') + .max(1000000, 'Amount exceeds maximum limit'), }), }); // Export types for TypeScript -export type GetRemittanceHistoryInput = z.infer< - typeof getRemittanceHistorySchema ->; +export type GetRemittanceHistoryInput = z.infer; export type SimulatePaymentInput = z.infer; diff --git a/backend/src/schemas/stellarSchemas.ts b/backend/src/schemas/stellarSchemas.ts index 643728d6..fc2582fc 100644 --- a/backend/src/schemas/stellarSchemas.ts +++ b/backend/src/schemas/stellarSchemas.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import { StrKey } from "@stellar/stellar-sdk"; +import { z } from 'zod'; +import { StrKey } from '@stellar/stellar-sdk'; /** * Zod schema for a Stellar Ed25519 public key (G... address). @@ -8,11 +8,8 @@ import { StrKey } from "@stellar/stellar-sdk"; */ export const stellarAddressSchema = z .string() - .min(1, "Stellar address is required") - .refine( - (val) => StrKey.isValidEd25519PublicKey(val), - "Invalid Stellar address format", - ); + .min(1, 'Stellar address is required') + .refine((val) => StrKey.isValidEd25519PublicKey(val), 'Invalid Stellar address format'); /** Param schema for routes with a :borrower path parameter. */ export const borrowerParamSchema = z.object({ diff --git a/backend/src/schemas/userSchemas.ts b/backend/src/schemas/userSchemas.ts index e9dae22e..ae605805 100644 --- a/backend/src/schemas/userSchemas.ts +++ b/backend/src/schemas/userSchemas.ts @@ -1,7 +1,6 @@ -import { z } from "zod"; +import { z } from 'zod'; -const nullableTrimmedString = (max: number) => - z.string().trim().max(max).nullable().optional(); +const nullableTrimmedString = (max: number) => z.string().trim().max(max).nullable().optional(); export const updateUserProfileSchema = z .object({ @@ -11,7 +10,7 @@ export const updateUserProfileSchema = z locale: z .string() .trim() - .regex(/^[a-z]{2}(-[A-Z]{2})?$/, "Locale must look like en or en-US") + .regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'Locale must look like en or en-US') .nullable() .optional(), avatarUrl: z.string().trim().url().max(2048).nullable().optional(), diff --git a/backend/src/seed/data/remittance.ts b/backend/src/seed/data/remittance.ts index 46e382fa..3c3eff9f 100644 --- a/backend/src/seed/data/remittance.ts +++ b/backend/src/seed/data/remittance.ts @@ -6,93 +6,93 @@ export interface SeedRemittance { } export const seedRemittances: SeedRemittance[] = [ - { user_id: "user_001", amount: 500, month: "January", status: "Completed" }, - { user_id: "user_001", amount: 500, month: "February", status: "Completed" }, - { user_id: "user_001", amount: 500, month: "March", status: "Completed" }, - { user_id: "user_001", amount: 600, month: "April", status: "Completed" }, - { user_id: "user_001", amount: 550, month: "May", status: "Completed" }, + { user_id: 'user_001', amount: 500, month: 'January', status: 'Completed' }, + { user_id: 'user_001', amount: 500, month: 'February', status: 'Completed' }, + { user_id: 'user_001', amount: 500, month: 'March', status: 'Completed' }, + { user_id: 'user_001', amount: 600, month: 'April', status: 'Completed' }, + { user_id: 'user_001', amount: 550, month: 'May', status: 'Completed' }, - { user_id: "user_002", amount: 300, month: "January", status: "Completed" }, - { user_id: "user_002", amount: 300, month: "February", status: "Completed" }, - { user_id: "user_002", amount: 300, month: "March", status: "Late" }, - { user_id: "user_002", amount: 350, month: "April", status: "Completed" }, - { user_id: "user_002", amount: 300, month: "May", status: "Completed" }, + { user_id: 'user_002', amount: 300, month: 'January', status: 'Completed' }, + { user_id: 'user_002', amount: 300, month: 'February', status: 'Completed' }, + { user_id: 'user_002', amount: 300, month: 'March', status: 'Late' }, + { user_id: 'user_002', amount: 350, month: 'April', status: 'Completed' }, + { user_id: 'user_002', amount: 300, month: 'May', status: 'Completed' }, - { user_id: "user_003", amount: 1000, month: "January", status: "Completed" }, - { user_id: "user_003", amount: 1000, month: "February", status: "Completed" }, - { user_id: "user_003", amount: 1000, month: "March", status: "Completed" }, - { user_id: "user_003", amount: 1200, month: "April", status: "Completed" }, - { user_id: "user_003", amount: 1100, month: "May", status: "Completed" }, + { user_id: 'user_003', amount: 1000, month: 'January', status: 'Completed' }, + { user_id: 'user_003', amount: 1000, month: 'February', status: 'Completed' }, + { user_id: 'user_003', amount: 1000, month: 'March', status: 'Completed' }, + { user_id: 'user_003', amount: 1200, month: 'April', status: 'Completed' }, + { user_id: 'user_003', amount: 1100, month: 'May', status: 'Completed' }, - { user_id: "user_004", amount: 200, month: "January", status: "Missed" }, - { user_id: "user_004", amount: 200, month: "February", status: "Completed" }, - { user_id: "user_004", amount: 200, month: "March", status: "Late" }, - { user_id: "user_004", amount: 250, month: "April", status: "Missed" }, - { user_id: "user_004", amount: 200, month: "May", status: "Completed" }, + { user_id: 'user_004', amount: 200, month: 'January', status: 'Missed' }, + { user_id: 'user_004', amount: 200, month: 'February', status: 'Completed' }, + { user_id: 'user_004', amount: 200, month: 'March', status: 'Late' }, + { user_id: 'user_004', amount: 250, month: 'April', status: 'Missed' }, + { user_id: 'user_004', amount: 200, month: 'May', status: 'Completed' }, - { user_id: "demo_user", amount: 450, month: "January", status: "Completed" }, - { user_id: "demo_user", amount: 450, month: "February", status: "Completed" }, - { user_id: "demo_user", amount: 500, month: "March", status: "Completed" }, - { user_id: "demo_user", amount: 450, month: "April", status: "Completed" }, - { user_id: "demo_user", amount: 475, month: "May", status: "Completed" }, + { user_id: 'demo_user', amount: 450, month: 'January', status: 'Completed' }, + { user_id: 'demo_user', amount: 450, month: 'February', status: 'Completed' }, + { user_id: 'demo_user', amount: 500, month: 'March', status: 'Completed' }, + { user_id: 'demo_user', amount: 450, month: 'April', status: 'Completed' }, + { user_id: 'demo_user', amount: 475, month: 'May', status: 'Completed' }, - { user_id: "test_user", amount: 250, month: "January", status: "Completed" }, - { user_id: "test_user", amount: 250, month: "February", status: "Late" }, - { user_id: "test_user", amount: 300, month: "March", status: "Completed" }, - { user_id: "test_user", amount: 275, month: "April", status: "Completed" }, - { user_id: "test_user", amount: 250, month: "May", status: "Completed" }, + { user_id: 'test_user', amount: 250, month: 'January', status: 'Completed' }, + { user_id: 'test_user', amount: 250, month: 'February', status: 'Late' }, + { user_id: 'test_user', amount: 300, month: 'March', status: 'Completed' }, + { user_id: 'test_user', amount: 275, month: 'April', status: 'Completed' }, + { user_id: 'test_user', amount: 250, month: 'May', status: 'Completed' }, { - user_id: "alice_stellar", + user_id: 'alice_stellar', amount: 800, - month: "January", - status: "Completed", + month: 'January', + status: 'Completed', }, { - user_id: "alice_stellar", + user_id: 'alice_stellar', amount: 850, - month: "February", - status: "Completed", + month: 'February', + status: 'Completed', }, { - user_id: "alice_stellar", + user_id: 'alice_stellar', amount: 900, - month: "March", - status: "Completed", + month: 'March', + status: 'Completed', }, { - user_id: "alice_stellar", + user_id: 'alice_stellar', amount: 875, - month: "April", - status: "Completed", + month: 'April', + status: 'Completed', }, - { user_id: "alice_stellar", amount: 950, month: "May", status: "Completed" }, + { user_id: 'alice_stellar', amount: 950, month: 'May', status: 'Completed' }, - { user_id: "bob_remit", amount: 600, month: "January", status: "Completed" }, - { user_id: "bob_remit", amount: 550, month: "February", status: "Completed" }, - { user_id: "bob_remit", amount: 600, month: "March", status: "Completed" }, - { user_id: "bob_remit", amount: 650, month: "April", status: "Completed" }, - { user_id: "bob_remit", amount: 600, month: "May", status: "Completed" }, + { user_id: 'bob_remit', amount: 600, month: 'January', status: 'Completed' }, + { user_id: 'bob_remit', amount: 550, month: 'February', status: 'Completed' }, + { user_id: 'bob_remit', amount: 600, month: 'March', status: 'Completed' }, + { user_id: 'bob_remit', amount: 650, month: 'April', status: 'Completed' }, + { user_id: 'bob_remit', amount: 600, month: 'May', status: 'Completed' }, { - user_id: "charlie_test", + user_id: 'charlie_test', amount: 400, - month: "January", - status: "Completed", + month: 'January', + status: 'Completed', }, { - user_id: "charlie_test", + user_id: 'charlie_test', amount: 450, - month: "February", - status: "Completed", + month: 'February', + status: 'Completed', }, - { user_id: "charlie_test", amount: 400, month: "March", status: "Completed" }, - { user_id: "charlie_test", amount: 425, month: "April", status: "Completed" }, - { user_id: "charlie_test", amount: 400, month: "May", status: "Completed" }, + { user_id: 'charlie_test', amount: 400, month: 'March', status: 'Completed' }, + { user_id: 'charlie_test', amount: 425, month: 'April', status: 'Completed' }, + { user_id: 'charlie_test', amount: 400, month: 'May', status: 'Completed' }, - { user_id: "user_005", amount: 350, month: "January", status: "Completed" }, - { user_id: "user_005", amount: 350, month: "February", status: "Completed" }, - { user_id: "user_005", amount: 400, month: "March", status: "Completed" }, - { user_id: "user_005", amount: 375, month: "April", status: "Completed" }, - { user_id: "user_005", amount: 350, month: "May", status: "Completed" }, + { user_id: 'user_005', amount: 350, month: 'January', status: 'Completed' }, + { user_id: 'user_005', amount: 350, month: 'February', status: 'Completed' }, + { user_id: 'user_005', amount: 400, month: 'March', status: 'Completed' }, + { user_id: 'user_005', amount: 375, month: 'April', status: 'Completed' }, + { user_id: 'user_005', amount: 350, month: 'May', status: 'Completed' }, ]; diff --git a/backend/src/seed/data/users.ts b/backend/src/seed/data/users.ts index 28c12572..977d4ff7 100644 --- a/backend/src/seed/data/users.ts +++ b/backend/src/seed/data/users.ts @@ -4,14 +4,14 @@ export interface SeedUser { } export const seedUsers: SeedUser[] = [ - { user_id: "user_001", current_score: 750 }, - { user_id: "user_002", current_score: 680 }, - { user_id: "user_003", current_score: 820 }, - { user_id: "user_004", current_score: 590 }, - { user_id: "user_005", current_score: 710 }, - { user_id: "demo_user", current_score: 700 }, - { user_id: "test_user", current_score: 650 }, - { user_id: "alice_stellar", current_score: 800 }, - { user_id: "bob_remit", current_score: 720 }, - { user_id: "charlie_test", current_score: 680 }, + { user_id: 'user_001', current_score: 750 }, + { user_id: 'user_002', current_score: 680 }, + { user_id: 'user_003', current_score: 820 }, + { user_id: 'user_004', current_score: 590 }, + { user_id: 'user_005', current_score: 710 }, + { user_id: 'demo_user', current_score: 700 }, + { user_id: 'test_user', current_score: 650 }, + { user_id: 'alice_stellar', current_score: 800 }, + { user_id: 'bob_remit', current_score: 720 }, + { user_id: 'charlie_test', current_score: 680 }, ]; diff --git a/backend/src/seed/index.ts b/backend/src/seed/index.ts index bdcb6a00..ed361bc1 100644 --- a/backend/src/seed/index.ts +++ b/backend/src/seed/index.ts @@ -1,15 +1,15 @@ -import dotenv from "dotenv"; -import { closePool, query } from "../db/connection.js"; -import logger from "../utils/logger.js"; +import dotenv from 'dotenv'; +import { closePool, query } from '../db/connection.js'; +import logger from '../utils/logger.js'; dotenv.config(); type NotificationType = - | "loan_approved" - | "repayment_due" - | "repayment_confirmed" - | "loan_defaulted" - | "score_changed"; + | 'loan_approved' + | 'repayment_due' + | 'repayment_confirmed' + | 'loan_defaulted' + | 'score_changed'; interface DevUser { userId: string; @@ -71,53 +71,51 @@ interface NotificationSeed { createdAt: Date; } -const NOW = new Date("2026-03-26T12:00:00.000Z"); -const CONTRACT_ID = - "CDDUMMYREMITLENDCONTRACT0000000000000000000000000000000000"; -const DEV_LENDER = - "GDEVLENDERACCOUNT000000000000000000000000000000000000000000"; +const NOW = new Date('2026-03-26T12:00:00.000Z'); +const CONTRACT_ID = 'CDDUMMYREMITLENDCONTRACT0000000000000000000000000000000000'; +const DEV_LENDER = 'GDEVLENDERACCOUNT000000000000000000000000000000000000000000'; const ACTIVE_TERM_LEDGERS = 17280; const devUsers: DevUser[] = [ { - userId: "GDEVUSERALICE000000000000000000000000000000000000000000001", - publicKey: "GDEVUSERALICE000000000000000000000000000000000000000000001", - displayName: "Alice Remit", - email: "alice@remitlend.dev", + userId: 'GDEVUSERALICE000000000000000000000000000000000000000000001', + publicKey: 'GDEVUSERALICE000000000000000000000000000000000000000000001', + displayName: 'Alice Remit', + email: 'alice@remitlend.dev', score: 782, - metadata: { role: "borrower", country: "NG", segment: "power-user" }, + metadata: { role: 'borrower', country: 'NG', segment: 'power-user' }, }, { - userId: "GDEVUSERBOLA000000000000000000000000000000000000000000002", - publicKey: "GDEVUSERBOLA000000000000000000000000000000000000000000002", - displayName: "Bola Credit", - email: "bola@remitlend.dev", + userId: 'GDEVUSERBOLA000000000000000000000000000000000000000000002', + publicKey: 'GDEVUSERBOLA000000000000000000000000000000000000000000002', + displayName: 'Bola Credit', + email: 'bola@remitlend.dev', score: 701, - metadata: { role: "borrower", country: "GH", segment: "growing" }, + metadata: { role: 'borrower', country: 'GH', segment: 'growing' }, }, { - userId: "GDEVUSERCHIDI00000000000000000000000000000000000000000003", - publicKey: "GDEVUSERCHIDI00000000000000000000000000000000000000000003", - displayName: "Chidi Default", - email: "chidi@remitlend.dev", + userId: 'GDEVUSERCHIDI00000000000000000000000000000000000000000003', + publicKey: 'GDEVUSERCHIDI00000000000000000000000000000000000000000003', + displayName: 'Chidi Default', + email: 'chidi@remitlend.dev', score: 611, - metadata: { role: "borrower", country: "KE", segment: "high-risk" }, + metadata: { role: 'borrower', country: 'KE', segment: 'high-risk' }, }, { - userId: "GDEVUSERDARA000000000000000000000000000000000000000000004", - publicKey: "GDEVUSERDARA000000000000000000000000000000000000000000004", - displayName: "Dara Pending", - email: "dara@remitlend.dev", + userId: 'GDEVUSERDARA000000000000000000000000000000000000000000004', + publicKey: 'GDEVUSERDARA000000000000000000000000000000000000000000004', + displayName: 'Dara Pending', + email: 'dara@remitlend.dev', score: 690, - metadata: { role: "borrower", country: "NG", segment: "new-user" }, + metadata: { role: 'borrower', country: 'NG', segment: 'new-user' }, }, { - userId: "GDEVUSEREFE0000000000000000000000000000000000000000000005", - publicKey: "GDEVUSEREFE0000000000000000000000000000000000000000000005", - displayName: "Efe Lender", - email: "efe@remitlend.dev", + userId: 'GDEVUSEREFE0000000000000000000000000000000000000000000005', + publicKey: 'GDEVUSEREFE0000000000000000000000000000000000000000000005', + displayName: 'Efe Lender', + email: 'efe@remitlend.dev', score: 820, - metadata: { role: "lender", country: "ZA", segment: "pool-provider" }, + metadata: { role: 'lender', country: 'ZA', segment: 'pool-provider' }, }, ]; @@ -133,49 +131,49 @@ const remittanceHistorySeeds: RemittanceSeed[] = [ { userId: aliceUser.userId, amount: 900, - month: "January", - status: "Completed", + month: 'January', + status: 'Completed', }, { userId: aliceUser.userId, amount: 950, - month: "February", - status: "Completed", + month: 'February', + status: 'Completed', }, { userId: aliceUser.userId, amount: 910, - month: "March", - status: "Completed", + month: 'March', + status: 'Completed', }, { userId: bolaUser.userId, amount: 600, - month: "January", - status: "Completed", + month: 'January', + status: 'Completed', }, - { userId: bolaUser.userId, amount: 625, month: "February", status: "Late" }, - { userId: bolaUser.userId, amount: 610, month: "March", status: "Completed" }, + { userId: bolaUser.userId, amount: 625, month: 'February', status: 'Late' }, + { userId: bolaUser.userId, amount: 610, month: 'March', status: 'Completed' }, { userId: chidiUser.userId, amount: 420, - month: "January", - status: "Completed", + month: 'January', + status: 'Completed', }, { userId: chidiUser.userId, amount: 400, - month: "February", - status: "Missed", + month: 'February', + status: 'Missed', }, - { userId: chidiUser.userId, amount: 390, month: "March", status: "Late" }, + { userId: chidiUser.userId, amount: 390, month: 'March', status: 'Late' }, { userId: daraUser.userId, amount: 500, - month: "January", - status: "Completed", + month: 'January', + status: 'Completed', }, - { userId: daraUser.userId, amount: 0, month: "February", status: "Pending" }, + { userId: daraUser.userId, amount: 0, month: 'February', status: 'Pending' }, ]; const loanHistorySeeds: LoanHistorySeed[] = [ @@ -188,11 +186,11 @@ const loanHistorySeeds: LoanHistorySeed[] = [ principalPaid: 0, interestPaid: 0, accruedInterest: 18, - status: "Active", - requestedAt: new Date("2026-03-20T10:00:00.000Z"), - approvedAt: new Date("2026-03-20T10:15:00.000Z"), - dueDate: new Date("2026-03-27T10:15:00.000Z"), - metadata: { stage: "active", purpose: "inventory restock" }, + status: 'Active', + requestedAt: new Date('2026-03-20T10:00:00.000Z'), + approvedAt: new Date('2026-03-20T10:15:00.000Z'), + dueDate: new Date('2026-03-27T10:15:00.000Z'), + metadata: { stage: 'active', purpose: 'inventory restock' }, }, { loanId: 1002, @@ -203,12 +201,12 @@ const loanHistorySeeds: LoanHistorySeed[] = [ principalPaid: 800, interestPaid: 24, accruedInterest: 0, - status: "Repaid", - requestedAt: new Date("2026-03-12T09:00:00.000Z"), - approvedAt: new Date("2026-03-12T09:30:00.000Z"), - repaidAt: new Date("2026-03-18T11:00:00.000Z"), - dueDate: new Date("2026-03-19T09:30:00.000Z"), - metadata: { stage: "repaid", purpose: "working capital" }, + status: 'Repaid', + requestedAt: new Date('2026-03-12T09:00:00.000Z'), + approvedAt: new Date('2026-03-12T09:30:00.000Z'), + repaidAt: new Date('2026-03-18T11:00:00.000Z'), + dueDate: new Date('2026-03-19T09:30:00.000Z'), + metadata: { stage: 'repaid', purpose: 'working capital' }, }, { loanId: 1003, @@ -219,12 +217,12 @@ const loanHistorySeeds: LoanHistorySeed[] = [ principalPaid: 200, interestPaid: 10, accruedInterest: 60, - status: "Defaulted", - requestedAt: new Date("2026-03-03T08:00:00.000Z"), - approvedAt: new Date("2026-03-03T08:25:00.000Z"), - defaultedAt: new Date("2026-03-16T14:00:00.000Z"), - dueDate: new Date("2026-03-10T08:25:00.000Z"), - metadata: { stage: "defaulted", purpose: "emergency remittance" }, + status: 'Defaulted', + requestedAt: new Date('2026-03-03T08:00:00.000Z'), + approvedAt: new Date('2026-03-03T08:25:00.000Z'), + defaultedAt: new Date('2026-03-16T14:00:00.000Z'), + dueDate: new Date('2026-03-10T08:25:00.000Z'), + metadata: { stage: 'defaulted', purpose: 'emergency remittance' }, }, { loanId: 1004, @@ -235,209 +233,208 @@ const loanHistorySeeds: LoanHistorySeed[] = [ principalPaid: 0, interestPaid: 0, accruedInterest: 0, - status: "Pending", - requestedAt: new Date("2026-03-25T16:00:00.000Z"), - dueDate: new Date("2026-04-01T16:00:00.000Z"), - metadata: { stage: "pending", purpose: "school fees" }, + status: 'Pending', + requestedAt: new Date('2026-03-25T16:00:00.000Z'), + dueDate: new Date('2026-04-01T16:00:00.000Z'), + metadata: { stage: 'pending', purpose: 'school fees' }, }, ]; const loanEventSeeds: LoanEventSeed[] = [ { - eventId: "seed-loan-1001-requested", - eventType: "LoanRequested", + eventId: 'seed-loan-1001-requested', + eventType: 'LoanRequested', loanId: 1001, address: aliceUser.publicKey, - amount: "1200", + amount: '1200', ledger: 240100, - ledgerClosedAt: new Date("2026-03-20T10:00:00.000Z"), - txHash: "seed-tx-1001-requested", + ledgerClosedAt: new Date('2026-03-20T10:00:00.000Z'), + txHash: 'seed-tx-1001-requested', contractId: CONTRACT_ID, - topics: ["LoanRequested", aliceUser.publicKey], - value: "1200", + topics: ['LoanRequested', aliceUser.publicKey], + value: '1200', }, { - eventId: "seed-loan-1001-approved", - eventType: "LoanApproved", + eventId: 'seed-loan-1001-approved', + eventType: 'LoanApproved', loanId: 1001, address: aliceUser.publicKey, - amount: "1200", + amount: '1200', ledger: 240105, - ledgerClosedAt: new Date("2026-03-20T10:15:00.000Z"), - txHash: "seed-tx-1001-approved", + ledgerClosedAt: new Date('2026-03-20T10:15:00.000Z'), + txHash: 'seed-tx-1001-approved', contractId: CONTRACT_ID, - topics: ["LoanApproved", "1001"], + topics: ['LoanApproved', '1001'], value: aliceUser.publicKey, interestRateBps: 1200, termLedgers: ACTIVE_TERM_LEDGERS, }, { - eventId: "seed-loan-1002-requested", - eventType: "LoanRequested", + eventId: 'seed-loan-1002-requested', + eventType: 'LoanRequested', loanId: 1002, address: bolaUser.publicKey, - amount: "800", + amount: '800', ledger: 239500, - ledgerClosedAt: new Date("2026-03-12T09:00:00.000Z"), - txHash: "seed-tx-1002-requested", + ledgerClosedAt: new Date('2026-03-12T09:00:00.000Z'), + txHash: 'seed-tx-1002-requested', contractId: CONTRACT_ID, - topics: ["LoanRequested", bolaUser.publicKey], - value: "800", + topics: ['LoanRequested', bolaUser.publicKey], + value: '800', }, { - eventId: "seed-loan-1002-approved", - eventType: "LoanApproved", + eventId: 'seed-loan-1002-approved', + eventType: 'LoanApproved', loanId: 1002, address: bolaUser.publicKey, - amount: "800", + amount: '800', ledger: 239506, - ledgerClosedAt: new Date("2026-03-12T09:30:00.000Z"), - txHash: "seed-tx-1002-approved", + ledgerClosedAt: new Date('2026-03-12T09:30:00.000Z'), + txHash: 'seed-tx-1002-approved', contractId: CONTRACT_ID, - topics: ["LoanApproved", "1002"], + topics: ['LoanApproved', '1002'], value: bolaUser.publicKey, interestRateBps: 900, termLedgers: ACTIVE_TERM_LEDGERS, }, { - eventId: "seed-loan-1002-repaid", - eventType: "LoanRepaid", + eventId: 'seed-loan-1002-repaid', + eventType: 'LoanRepaid', loanId: 1002, address: bolaUser.publicKey, - amount: "824", + amount: '824', ledger: 239900, - ledgerClosedAt: new Date("2026-03-18T11:00:00.000Z"), - txHash: "seed-tx-1002-repaid", + ledgerClosedAt: new Date('2026-03-18T11:00:00.000Z'), + txHash: 'seed-tx-1002-repaid', contractId: CONTRACT_ID, - topics: ["LoanRepaid", bolaUser.publicKey, "1002"], - value: "824", + topics: ['LoanRepaid', bolaUser.publicKey, '1002'], + value: '824', }, { - eventId: "seed-loan-1003-requested", - eventType: "LoanRequested", + eventId: 'seed-loan-1003-requested', + eventType: 'LoanRequested', loanId: 1003, address: chidiUser.publicKey, - amount: "650", + amount: '650', ledger: 238200, - ledgerClosedAt: new Date("2026-03-03T08:00:00.000Z"), - txHash: "seed-tx-1003-requested", + ledgerClosedAt: new Date('2026-03-03T08:00:00.000Z'), + txHash: 'seed-tx-1003-requested', contractId: CONTRACT_ID, - topics: ["LoanRequested", chidiUser.publicKey], - value: "650", + topics: ['LoanRequested', chidiUser.publicKey], + value: '650', }, { - eventId: "seed-loan-1003-approved", - eventType: "LoanApproved", + eventId: 'seed-loan-1003-approved', + eventType: 'LoanApproved', loanId: 1003, address: chidiUser.publicKey, - amount: "650", + amount: '650', ledger: 238205, - ledgerClosedAt: new Date("2026-03-03T08:25:00.000Z"), - txHash: "seed-tx-1003-approved", + ledgerClosedAt: new Date('2026-03-03T08:25:00.000Z'), + txHash: 'seed-tx-1003-approved', contractId: CONTRACT_ID, - topics: ["LoanApproved", "1003"], + topics: ['LoanApproved', '1003'], value: chidiUser.publicKey, interestRateBps: 1500, termLedgers: ACTIVE_TERM_LEDGERS, }, { - eventId: "seed-loan-1003-repaid-partial", - eventType: "LoanRepaid", + eventId: 'seed-loan-1003-repaid-partial', + eventType: 'LoanRepaid', loanId: 1003, address: chidiUser.publicKey, - amount: "210", + amount: '210', ledger: 238600, - ledgerClosedAt: new Date("2026-03-08T10:00:00.000Z"), - txHash: "seed-tx-1003-repaid-partial", + ledgerClosedAt: new Date('2026-03-08T10:00:00.000Z'), + txHash: 'seed-tx-1003-repaid-partial', contractId: CONTRACT_ID, - topics: ["LoanRepaid", chidiUser.publicKey, "1003"], - value: "210", + topics: ['LoanRepaid', chidiUser.publicKey, '1003'], + value: '210', }, { - eventId: "seed-loan-1003-defaulted", - eventType: "LoanDefaulted", + eventId: 'seed-loan-1003-defaulted', + eventType: 'LoanDefaulted', loanId: 1003, address: chidiUser.publicKey, ledger: 239100, - ledgerClosedAt: new Date("2026-03-16T14:00:00.000Z"), - txHash: "seed-tx-1003-defaulted", + ledgerClosedAt: new Date('2026-03-16T14:00:00.000Z'), + txHash: 'seed-tx-1003-defaulted', contractId: CONTRACT_ID, - topics: ["LoanDefaulted", "1003"], + topics: ['LoanDefaulted', '1003'], value: chidiUser.publicKey, }, { - eventId: "seed-loan-1004-requested", - eventType: "LoanRequested", + eventId: 'seed-loan-1004-requested', + eventType: 'LoanRequested', loanId: 1004, address: daraUser.publicKey, - amount: "500", + amount: '500', ledger: 240990, - ledgerClosedAt: new Date("2026-03-25T16:00:00.000Z"), - txHash: "seed-tx-1004-requested", + ledgerClosedAt: new Date('2026-03-25T16:00:00.000Z'), + txHash: 'seed-tx-1004-requested', contractId: CONTRACT_ID, - topics: ["LoanRequested", daraUser.publicKey], - value: "500", + topics: ['LoanRequested', daraUser.publicKey], + value: '500', }, ]; const notificationSeeds: NotificationSeed[] = [ { userId: aliceUser.userId, - type: "repayment_due", - title: "Repayment Due Soon", - message: "Loan #1001 repayment window closes tomorrow.", + type: 'repayment_due', + title: 'Repayment Due Soon', + message: 'Loan #1001 repayment window closes tomorrow.', loanId: 1001, read: false, - createdAt: new Date("2026-03-25T12:00:00.000Z"), + createdAt: new Date('2026-03-25T12:00:00.000Z'), }, { userId: bolaUser.userId, - type: "loan_approved", - title: "Loan Approved", - message: "Your loan #1002 is funded and ready for use.", + type: 'loan_approved', + title: 'Loan Approved', + message: 'Your loan #1002 is funded and ready for use.', loanId: 1002, read: true, - createdAt: new Date("2026-03-12T09:35:00.000Z"), + createdAt: new Date('2026-03-12T09:35:00.000Z'), }, { userId: bolaUser.userId, - type: "repayment_confirmed", - title: "Repayment Confirmed", - message: "Loan #1002 has been fully repaid.", + type: 'repayment_confirmed', + title: 'Repayment Confirmed', + message: 'Loan #1002 has been fully repaid.', loanId: 1002, read: false, - createdAt: new Date("2026-03-18T11:05:00.000Z"), + createdAt: new Date('2026-03-18T11:05:00.000Z'), }, { userId: chidiUser.userId, - type: "loan_defaulted", - title: "Loan Defaulted", - message: - "Loan #1003 has been marked defaulted after missed repayment windows.", + type: 'loan_defaulted', + title: 'Loan Defaulted', + message: 'Loan #1003 has been marked defaulted after missed repayment windows.', loanId: 1003, read: false, - createdAt: new Date("2026-03-16T14:05:00.000Z"), + createdAt: new Date('2026-03-16T14:05:00.000Z'), }, { userId: chidiUser.userId, - type: "score_changed", - title: "Credit Score Updated", - message: "Your borrower score was adjusted after recent loan activity.", + type: 'score_changed', + title: 'Credit Score Updated', + message: 'Your borrower score was adjusted after recent loan activity.', loanId: 1003, read: true, - createdAt: new Date("2026-03-16T14:10:00.000Z"), + createdAt: new Date('2026-03-16T14:10:00.000Z'), }, ]; const parseArgs = () => { const args = new Set(process.argv.slice(2)); return { - reset: args.has("--reset"), + reset: args.has('--reset'), }; }; const seedUserProfiles = async () => { - logger.info("Seeding user_profiles..."); + logger.info('Seeding user_profiles...'); for (const user of devUsers) { await query( @@ -455,7 +452,7 @@ const seedUserProfiles = async () => { }; const seedScores = async () => { - logger.info("Seeding scores..."); + logger.info('Seeding scores...'); for (const user of devUsers) { await query( @@ -471,7 +468,7 @@ const seedScores = async () => { }; const seedRemittanceHistory = async () => { - logger.info("Seeding remittance_history..."); + logger.info('Seeding remittance_history...'); for (const remittance of remittanceHistorySeeds) { const existing = await query( @@ -486,12 +483,7 @@ const seedRemittanceHistory = async () => { `UPDATE remittance_history SET amount = $3, status = $4 WHERE user_id = $1 AND month = $2`, - [ - remittance.userId, - remittance.month, - remittance.amount, - remittance.status, - ], + [remittance.userId, remittance.month, remittance.amount, remittance.status], ); continue; } @@ -499,25 +491,16 @@ const seedRemittanceHistory = async () => { await query( `INSERT INTO remittance_history (user_id, amount, month, status, created_at) VALUES ($1, $2, $3, $4, $5)`, - [ - remittance.userId, - remittance.amount, - remittance.month, - remittance.status, - NOW, - ], + [remittance.userId, remittance.amount, remittance.month, remittance.status, NOW], ); } }; const seedLoanHistory = async () => { - logger.info("Seeding loan_history..."); + logger.info('Seeding loan_history...'); for (const loan of loanHistorySeeds) { - const existing = await query( - `SELECT id FROM loan_history WHERE loan_id = $1`, - [loan.loanId], - ); + const existing = await query(`SELECT id FROM loan_history WHERE loan_id = $1`, [loan.loanId]); if ((existing.rowCount ?? 0) > 0) { await query( @@ -603,7 +586,7 @@ const seedLoanHistory = async () => { }; const seedLoanEvents = async () => { - logger.info("Seeding contract_events..."); + logger.info('Seeding contract_events...'); for (const event of loanEventSeeds) { await query( @@ -659,7 +642,7 @@ const seedLoanEvents = async () => { }; const seedNotifications = async () => { - logger.info("Seeding notifications..."); + logger.info('Seeding notifications...'); for (const notification of notificationSeeds) { const existing = await query( @@ -718,14 +701,10 @@ const seedNotifications = async () => { }; const seedIndexerState = async () => { - logger.info("Updating indexer_state..."); + logger.info('Updating indexer_state...'); - const lastSeededLedger = Math.max( - ...loanEventSeeds.map((event) => event.ledger), - ); - const existing = await query( - `SELECT id FROM indexer_state ORDER BY id DESC LIMIT 1`, - ); + const lastSeededLedger = Math.max(...loanEventSeeds.map((event) => event.ledger)); + const existing = await query(`SELECT id FROM indexer_state ORDER BY id DESC LIMIT 1`); if ((existing.rowCount ?? 0) > 0) { await query( @@ -734,7 +713,7 @@ const seedIndexerState = async () => { last_indexed_cursor = $2, updated_at = CURRENT_TIMESTAMP WHERE id = (SELECT id FROM indexer_state ORDER BY id DESC LIMIT 1)`, - [lastSeededLedger, "seeded-dev-data"], + [lastSeededLedger, 'seeded-dev-data'], ); return; } @@ -742,12 +721,12 @@ const seedIndexerState = async () => { await query( `INSERT INTO indexer_state (last_indexed_ledger, last_indexed_cursor, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP)`, - [lastSeededLedger, "seeded-dev-data"], + [lastSeededLedger, 'seeded-dev-data'], ); }; const resetDevelopmentData = async () => { - logger.info("Resetting development seed data..."); + logger.info('Resetting development seed data...'); await query( `TRUNCATE TABLE @@ -769,7 +748,7 @@ const resetDevelopmentData = async () => { }; const logSummary = () => { - logger.info("Development data summary", { + logger.info('Development data summary', { users: devUsers.length, remittances: remittanceHistorySeeds.length, loanHistory: loanHistorySeeds.length, @@ -781,11 +760,11 @@ const logSummary = () => { const runSeed = async () => { const { reset } = parseArgs(); - logger.info("Starting development database seeding..."); - logger.info("=".repeat(50)); + logger.info('Starting development database seeding...'); + logger.info('='.repeat(50)); try { - await query("BEGIN"); + await query('BEGIN'); if (reset) { await resetDevelopmentData(); @@ -799,15 +778,15 @@ const runSeed = async () => { await seedNotifications(); await seedIndexerState(); - await query("COMMIT"); + await query('COMMIT'); - logger.info(""); - logger.info("=".repeat(50)); - logger.info("Development database seeding completed successfully!"); + logger.info(''); + logger.info('='.repeat(50)); + logger.info('Development database seeding completed successfully!'); logSummary(); } catch (error) { - await query("ROLLBACK").catch(() => undefined); - logger.error("Error during development seeding", { + await query('ROLLBACK').catch(() => undefined); + logger.error('Error during development seeding', { message: error instanceof Error ? error.message : String(error), ...(error instanceof Error && error.stack && { stack: error.stack }), }); diff --git a/backend/src/services/README.md b/backend/src/services/README.md index 74ec2e16..83485e17 100644 --- a/backend/src/services/README.md +++ b/backend/src/services/README.md @@ -1,4 +1,6 @@ -# Event Indexer Service +# Services + +## Event Indexer Service ## Overview @@ -355,13 +357,13 @@ For high-volume contracts: ### Integration Testing ```typescript -import { EventIndexer } from "./eventIndexer"; +import { EventIndexer } from './eventIndexer'; -describe("EventIndexer", () => { - it("should index loan events", async () => { +describe('EventIndexer', () => { + it('should index loan events', async () => { const indexer = new EventIndexer({ - rpcUrl: "https://soroban-testnet.stellar.org", - contractId: "CTEST...", + rpcUrl: 'https://soroban-testnet.stellar.org', + contractId: 'CTEST...', pollIntervalMs: 5000, batchSize: 10, }); @@ -392,3 +394,41 @@ describe("EventIndexer", () => { - [Stellar RPC getEvents Documentation](https://developers.stellar.org/docs/data/rpc/api-reference/methods/getEvents) - [Soroban Events Guide](https://developers.stellar.org/docs/smart-contracts/guides/events/ingest) - [Stellar SDK Documentation](https://stellar.github.io/js-stellar-sdk/) + +--- + +## Background Jobs + +The backend runs several scheduled background jobs to maintain system integrity and sync with on-chain state. All jobs start automatically when the API process launches. + +### Active Jobs + +| Job | Schedule | Env Var | Default | Description | Source | +| --------------------------- | ---------------- | ----------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- | +| **Event Indexer** | Continuous poll | `INDEXER_POLL_INTERVAL_MS` | 30000ms (30s) | Syncs on-chain events to PostgreSQL for fast queries | `services/eventIndexer.ts` | +| **Default Checker** | Interval | `DEFAULT_CHECK_INTERVAL_MS` | 1800000ms (30m) | Calls on-chain `check_defaults` batch for overdue loans | `services/defaultChecker.ts` | +| **Webhook Retry Scheduler** | Fixed 60s | — | 60s | Retries failed webhook deliveries using exponential backoff | `services/webhookRetryScheduler.ts` | +| **Score Reconciliation** | Interval | `SCORE_RECONCILIATION_INTERVAL_MS` | 3600000ms (1h) | Compares database scores vs on-chain scores and optionally auto-corrects divergence | `services/scoreReconciliationService.ts` | +| **Notification Cleanup** | Fixed 24h | `NOTIFICATION_RETENTION_DAYS`, `READ_NOTIFICATION_RETENTION_DAYS` | 24h | Deletes old read/unread notifications per retention policy | `services/notificationService.ts` | +| **Loan Due Check** | Cron `0 * * * *` | — | Top of every hour | Notifies borrowers about upcoming loan repayments | `cron/loanCheckCron.ts` | + +### Jobs Defined But Not Wired + +These services exist in the codebase but are **not currently started** in `index.ts`: + +- **`scoreDecayJob`** (`cron/scoreDecayJob.ts`) — Periodic credit score decay logic (not scheduled) +- **`webhookRetryProcessor`** (`services/webhookRetryProcessor.ts`) — Alternative webhook retry implementation (superseded by `webhookRetryScheduler`) + +### Related Documentation + +- [Indexer Recovery Runbook](../../docs/runbooks/indexer-recovery.md) — Troubleshooting indexer lag and manual re-sync +- [Webhooks Guide](../../docs/webhooks.md) — Webhook retry behavior and signature verification + +### Monitoring + +Key metrics to track for job health: + +- **Indexer lag**: `current_ledger - last_indexed_ledger` (surfaced in `/health/deep`) +- **Default check success rate**: Logged in `jobMetricsService` +- **Webhook retry queue depth**: Query `webhook_deliveries` where `delivered_at IS NULL` +- **Score divergence count**: Logged after each reconciliation run diff --git a/backend/src/services/__tests__/eventIndexer.test.ts b/backend/src/services/__tests__/eventIndexer.test.ts index b02ed3e4..0d41c6b9 100644 --- a/backend/src/services/__tests__/eventIndexer.test.ts +++ b/backend/src/services/__tests__/eventIndexer.test.ts @@ -8,15 +8,7 @@ * 4. Duplicate events (ON CONFLICT DO NOTHING) do not trigger score updates */ -import { - jest, - describe, - it, - expect, - beforeAll, - beforeEach, - afterEach, -} from "@jest/globals"; +import { jest, describe, it, expect, beforeAll, beforeEach, afterEach } from '@jest/globals'; // -------------------------------------------------------------------------- // Mock declarations @@ -41,7 +33,7 @@ interface MockClient { // -------------------------------------------------------------------------- /** Build a raw Soroban event that parses as LoanRepaid with borrower="addr" */ -function makeRawRepaidEvent(id = "event-001"): Record { +function makeRawRepaidEvent(id = 'event-001'): Record { const makeSym = (name: string) => ({ sym: () => ({ toString: () => name }), toXDR: (_enc: string) => `xdr:${name}`, @@ -51,27 +43,25 @@ function makeRawRepaidEvent(id = "event-001"): Record { id, pagingToken: id, topic: [ - makeSym("LoanRepaid"), - { sym: () => ({ toString: () => "addr" }), toXDR: () => "xdr:addr" }, - { sym: () => ({ toString: () => "1" }), toXDR: () => "xdr:1" }, + makeSym('LoanRepaid'), + { sym: () => ({ toString: () => 'addr' }), toXDR: () => 'xdr:addr' }, + { sym: () => ({ toString: () => '1' }), toXDR: () => 'xdr:1' }, ], value: { _val: 1000n, sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:val", + toXDR: () => 'xdr:val', }, ledger: 100, ledgerClosedAt: new Date().toISOString(), - txHash: "txhash001", - contractId: { toString: () => "CONTRACT001" }, + txHash: 'txhash001', + contractId: { toString: () => 'CONTRACT001' }, }; } -function makeRawAdminConfigEvent( - id = "admin-evt-001", -): Record { +function makeRawAdminConfigEvent(id = 'admin-evt-001'): Record { const makeSym = (name: string) => ({ sym: () => ({ toString: () => name }), toXDR: (_enc: string) => `xdr:${name}`, @@ -81,23 +71,23 @@ function makeRawAdminConfigEvent( id, pagingToken: id, topic: [ - makeSym("LateFeeRateUpdated"), + makeSym('LateFeeRateUpdated'), { - sym: () => ({ toString: () => "admin-addr" }), - toXDR: () => "xdr:admin", + sym: () => ({ toString: () => 'admin-addr' }), + toXDR: () => 'xdr:admin', }, ], value: { _val: [10n, 25n], sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:admin-val", + toXDR: () => 'xdr:admin-val', }, ledger: 101, ledgerClosedAt: new Date().toISOString(), - txHash: "txhash-admin-001", - contractId: { toString: () => "CONTRACT001" }, + txHash: 'txhash-admin-001', + contractId: { toString: () => 'CONTRACT001' }, }; } @@ -107,7 +97,7 @@ function makeRawAdminConfigEvent( * topic[1] = admin address ("GADMIN123") * value = [loanId=42, borrower="GBORROWER123"] */ -function makeRawLoanApprvEvent(id = "apprv-001"): Record { +function makeRawLoanApprvEvent(id = 'apprv-001'): Record { const makeSym = (name: string) => ({ sym: () => ({ toString: () => name }), toXDR: (_enc: string) => `xdr:${name}`, @@ -117,28 +107,28 @@ function makeRawLoanApprvEvent(id = "apprv-001"): Record { id, pagingToken: id, topic: [ - makeSym("LoanApprv"), + makeSym('LoanApprv'), // admin address — _val makes scValToNative return a string { - _val: "GADMIN123", + _val: 'GADMIN123', sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:admin", + toXDR: () => 'xdr:admin', }, ], value: { // scValToNative returns [42, "GBORROWER123"] for arrays - _val: [42, "GBORROWER123"], + _val: [42, 'GBORROWER123'], sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:apprv-val", + toXDR: () => 'xdr:apprv-val', }, ledger: 200, ledgerClosedAt: new Date().toISOString(), - txHash: "txhash-apprv-001", - contractId: { toString: () => "CONTRACT001" }, + txHash: 'txhash-apprv-001', + contractId: { toString: () => 'CONTRACT001' }, }; } @@ -147,7 +137,7 @@ function makeRawLoanApprvEvent(id = "apprv-001"): Record { * topic[0] = "LoanLiquidated", topic[1] = loan_id=7, topic[2] = borrower="GBORROWER456", topic[3] = liquidator * value = [debt_repaid=5000, liquidator_bonus=500, borrower_refund=200] */ -function makeRawLoanLiquidatedEvent(id = "liq-001"): Record { +function makeRawLoanLiquidatedEvent(id = 'liq-001'): Record { const makeSym = (name: string) => ({ sym: () => ({ toString: () => name }), toXDR: (_enc: string) => `xdr:${name}`, @@ -157,47 +147,47 @@ function makeRawLoanLiquidatedEvent(id = "liq-001"): Record { id, pagingToken: id, topic: [ - makeSym("LoanLiquidated"), + makeSym('LoanLiquidated'), { _val: 7, sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:loanid", + toXDR: () => 'xdr:loanid', }, { - _val: "GBORROWER456", + _val: 'GBORROWER456', sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:borrower", + toXDR: () => 'xdr:borrower', }, { - _val: "GLIQUIDATOR789", + _val: 'GLIQUIDATOR789', sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:liquidator", + toXDR: () => 'xdr:liquidator', }, ], value: { _val: [5000n, 500n, 200n], sym: () => { - throw new Error("not a sym"); + throw new Error('not a sym'); }, - toXDR: () => "xdr:liq-val", + toXDR: () => 'xdr:liq-val', }, ledger: 300, ledgerClosedAt: new Date().toISOString(), - txHash: "txhash-liq-001", - contractId: { toString: () => "CONTRACT001" }, + txHash: 'txhash-liq-001', + contractId: { toString: () => 'CONTRACT001' }, }; } /** Run the withTransaction callback immediately using the provided mock client. */ function stubWithTransaction(mockClient: MockClient): void { - (mockWithTransaction as jest.Mock).mockImplementation( - async (fn: TxCallback) => fn(mockClient), + (mockWithTransaction as jest.Mock).mockImplementation(async (fn: TxCallback) => + fn(mockClient), ); } @@ -206,107 +196,97 @@ function stubWithTransaction(mockClient: MockClient): void { // -------------------------------------------------------------------------- let EventIndexer: new (options: { rpcUrl: string; contractIds: string[] }) => { - ingestRawEvents: ( - events: Record[], - ) => Promise<{ insertedCount: number }>; + ingestRawEvents: (events: Record[]) => Promise<{ insertedCount: number }>; }; beforeAll(async () => { mockWithTransaction = jest.fn(); - mockUpdateUserScoresBulk = jest - .fn<() => Promise>() - .mockResolvedValue(undefined); + mockUpdateUserScoresBulk = jest.fn<() => Promise>().mockResolvedValue(undefined); mockSorobanGetScoreConfig = jest .fn<() => { repaymentDelta: number; defaultPenalty: number }>() .mockReturnValue({ repaymentDelta: 10, defaultPenalty: 20 }); - mockWebhookDispatch = jest - .fn<() => Promise>() - .mockResolvedValue(undefined); + mockWebhookDispatch = jest.fn<() => Promise>().mockResolvedValue(undefined); mockEventStreamBroadcast = jest.fn(); - mockNotificationCreate = jest - .fn<() => Promise>() - .mockResolvedValue(undefined); + mockNotificationCreate = jest.fn<() => Promise>().mockResolvedValue(undefined); - jest.unstable_mockModule("../../db/connection.js", () => ({ + jest.unstable_mockModule('../../db/connection.js', () => ({ query: jest - .fn< - (...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }> - >() + .fn<(...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>() .mockResolvedValue({ rows: [], rowCount: 0 } as never), getClient: jest.fn(), withTransaction: mockWithTransaction, - TRANSIENT_ERROR_CODES: new Set(["08006", "57P01", "40001"]), + TRANSIENT_ERROR_CODES: new Set(['08006', '57P01', '40001']), })); - jest.unstable_mockModule("../scoresService.js", () => ({ + jest.unstable_mockModule('../scoresService.js', () => ({ updateUserScoresBulk: mockUpdateUserScoresBulk, })); - jest.unstable_mockModule("../sorobanService.js", () => ({ + jest.unstable_mockModule('../sorobanService.js', () => ({ sorobanService: { getScoreConfig: mockSorobanGetScoreConfig }, })); - jest.unstable_mockModule("../webhookService.js", () => ({ + jest.unstable_mockModule('../webhookService.js', () => ({ webhookService: { dispatch: mockWebhookDispatch }, IndexedLoanEvent: {}, WebhookEventType: {}, SUPPORTED_WEBHOOK_EVENT_TYPES: [ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", - "CollateralLiquidated", - "LoanLiquidated", - "Deposit", - "Withdraw", - "YieldDistributed", - "EmergencyWithdraw", - "NFTMinted", - "ScoreUpdated", - "NFTSeized", - "NFTBurned", - "ProposalCreated", - "ProposalApproved", - "ProposalFinalized", - "Mint", - "ScoreUpd", - "Seized", - "GovProp", - "GovAppr", - "GovFin", - "Transfer", - "MntAuth", - "MntRev", - "Paused", - "Unpaused", - "MinScoreUpdated", - "InterestRateUpdated", - "DefaultTermUpdated", - "TermLimitsUpdated", - "LateFeeRateUpdated", - "GracePeriodUpdated", - "DefaultWindowUpdated", - "MaxLoanAmountUpdated", - "MinRepaymentUpdated", - "MaxLoansPerBorrower", - "MinRateBpsUpdated", - "MaxRateBpsUpdated", - "RateOracleUpdated", - "PoolPaused", - "PoolUnpaused", - "LoanApprv", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', + 'CollateralLiquidated', + 'LoanLiquidated', + 'Deposit', + 'Withdraw', + 'YieldDistributed', + 'EmergencyWithdraw', + 'NFTMinted', + 'ScoreUpdated', + 'NFTSeized', + 'NFTBurned', + 'ProposalCreated', + 'ProposalApproved', + 'ProposalFinalized', + 'Mint', + 'ScoreUpd', + 'Seized', + 'GovProp', + 'GovAppr', + 'GovFin', + 'Transfer', + 'MntAuth', + 'MntRev', + 'Paused', + 'Unpaused', + 'MinScoreUpdated', + 'InterestRateUpdated', + 'DefaultTermUpdated', + 'TermLimitsUpdated', + 'LateFeeRateUpdated', + 'GracePeriodUpdated', + 'DefaultWindowUpdated', + 'MaxLoanAmountUpdated', + 'MinRepaymentUpdated', + 'MaxLoansPerBorrower', + 'MinRateBpsUpdated', + 'MaxRateBpsUpdated', + 'RateOracleUpdated', + 'PoolPaused', + 'PoolUnpaused', + 'LoanApprv', ], })); - jest.unstable_mockModule("../eventStreamService.js", () => ({ + jest.unstable_mockModule('../eventStreamService.js', () => ({ eventStreamService: { broadcast: mockEventStreamBroadcast }, })); - jest.unstable_mockModule("../notificationService.js", () => ({ + jest.unstable_mockModule('../notificationService.js', () => ({ notificationService: { createNotification: mockNotificationCreate }, })); - jest.unstable_mockModule("../../utils/logger.js", () => ({ + jest.unstable_mockModule('../../utils/logger.js', () => ({ default: { warn: jest.fn(), error: jest.fn(), @@ -315,45 +295,36 @@ beforeAll(async () => { }, })); - jest.unstable_mockModule("../../utils/requestContext.js", () => ({ - createRequestId: jest.fn().mockReturnValue("test-req-id"), - runWithRequestContext: jest.fn((_id: string, fn: () => Promise) => - fn(), - ), + jest.unstable_mockModule('../../utils/requestContext.js', () => ({ + createRequestId: jest.fn().mockReturnValue('test-req-id'), + runWithRequestContext: jest.fn((_id: string, fn: () => Promise) => fn()), })); - jest.unstable_mockModule("@stellar/stellar-sdk", () => ({ + jest.unstable_mockModule('@stellar/stellar-sdk', () => ({ rpc: { - Server: jest - .fn<(...args: unknown[]) => unknown>() - .mockImplementation(() => ({ - getEvents: jest - .fn<() => Promise<{ events: unknown[] }>>() - .mockResolvedValue({ events: [] } as never), - getLatestLedger: jest - .fn<() => Promise<{ sequence: number }>>() - .mockResolvedValue({ sequence: 0 } as never), - })), + Server: jest.fn<(...args: unknown[]) => unknown>().mockImplementation(() => ({ + getEvents: jest + .fn<() => Promise<{ events: unknown[] }>>() + .mockResolvedValue({ events: [] } as never), + getLatestLedger: jest + .fn<() => Promise<{ sequence: number }>>() + .mockResolvedValue({ sequence: 0 } as never), + })), }, scValToNative: jest.fn((val: Record) => { const v = val as Record; if (v._val !== undefined) return v._val; - return ( - ( - v.sym as unknown as () => { toString: () => string } - )?.()?.toString?.() ?? "" - ); + return (v.sym as unknown as () => { toString: () => string })?.()?.toString?.() ?? ''; }), xdr: { ScVal: {} as never }, })); - jest.unstable_mockModule("../../errors/AppError.js", () => ({ + jest.unstable_mockModule('../../errors/AppError.js', () => ({ AppError: { badRequest: (msg: string) => new Error(msg) }, })); - const mod = await import("../eventIndexer.js"); - EventIndexer = (mod as unknown as { EventIndexer: typeof EventIndexer }) - .EventIndexer; + const mod = await import('../eventIndexer.js'); + EventIndexer = (mod as unknown as { EventIndexer: typeof EventIndexer }).EventIndexer; }); beforeEach(() => { @@ -372,8 +343,8 @@ afterEach(() => { function makeIndexer() { return new EventIndexer({ - rpcUrl: "http://localhost:8000", - contractIds: ["CONTRACT001"], + rpcUrl: 'http://localhost:8000', + contractIds: ['CONTRACT001'], }); } @@ -381,18 +352,18 @@ function makeIndexer() { // Tests // -------------------------------------------------------------------------- -describe("EventIndexer – transaction atomicity via ingestRawEvents", () => { - it("happy path: event insert succeeds and score update is called with the pinned client", async () => { +describe('EventIndexer – transaction atomicity via ingestRawEvents', () => { + it('happy path: event insert succeeds and score update is called with the pinned client', async () => { const mockClient: MockClient = { query: jest.fn().mockResolvedValue({ rowCount: 1, - rows: [{ event_id: "event-001" }], + rows: [{ event_id: 'event-001' }], } as never), }; stubWithTransaction(mockClient); const result = await (makeIndexer().ingestRawEvents([ - makeRawRepaidEvent("event-001"), + makeRawRepaidEvent('event-001'), ]) as Promise<{ insertedCount: number }>); // withTransaction must have been called once @@ -409,84 +380,74 @@ describe("EventIndexer – transaction atomicity via ingestRawEvents", () => { ]; expect(passedClient).toBe(mockClient); // LoanRepaid for borrower "addr" with repaymentDelta 10 - expect([...updates.entries()]).toEqual([["addr", 10]]); + expect([...updates.entries()]).toEqual([['addr', 10]]); }); - it("score update failure propagates — the whole operation throws", async () => { + it('score update failure propagates — the whole operation throws', async () => { const mockClient: MockClient = { query: jest.fn().mockResolvedValue({ rowCount: 1, - rows: [{ event_id: "event-rollback" }], + rows: [{ event_id: 'event-rollback' }], } as never), }; // withTransaction executes the callback but re-throws when it throws - (mockWithTransaction as jest.Mock).mockImplementation( - async (fn: TxCallback) => { - try { - return await fn(mockClient); - } catch (err) { - throw err; // simulate rollback + re-throw - } - }, - ); - (mockUpdateUserScoresBulk as jest.Mock).mockRejectedValueOnce( - new Error("score db fail"), - ); + (mockWithTransaction as jest.Mock).mockImplementation(async (fn: TxCallback) => { + try { + return await fn(mockClient); + } catch (err) { + throw err; // simulate rollback + re-throw + } + }); + (mockUpdateUserScoresBulk as jest.Mock).mockRejectedValueOnce(new Error('score db fail')); await expect( - makeIndexer().ingestRawEvents([ - makeRawRepaidEvent("event-rollback"), - ]) as Promise, - ).rejects.toThrow("score db fail"); + makeIndexer().ingestRawEvents([makeRawRepaidEvent('event-rollback')]) as Promise, + ).rejects.toThrow('score db fail'); expect(mockWithTransaction).toHaveBeenCalledTimes(1); }); - it("event INSERT failure propagates before score update runs", async () => { - const insertError = Object.assign(new Error("insert constraint violated"), { - code: "23505", + it('event INSERT failure propagates before score update runs', async () => { + const insertError = Object.assign(new Error('insert constraint violated'), { + code: '23505', }); const mockClient: MockClient = { query: jest.fn().mockRejectedValueOnce(insertError as never), }; - (mockWithTransaction as jest.Mock).mockImplementation( - async (fn: TxCallback) => { - try { - return await fn(mockClient); - } catch (err) { - throw err; - } - }, - ); + (mockWithTransaction as jest.Mock).mockImplementation(async (fn: TxCallback) => { + try { + return await fn(mockClient); + } catch (err) { + throw err; + } + }); await expect( - makeIndexer().ingestRawEvents([ - makeRawRepaidEvent("event-insert-fail"), - ]) as Promise, - ).rejects.toThrow("insert constraint violated"); + makeIndexer().ingestRawEvents([makeRawRepaidEvent('event-insert-fail')]) as Promise, + ).rejects.toThrow('insert constraint violated'); // Score update must not have been reached expect(mockUpdateUserScoresBulk).not.toHaveBeenCalled(); }); - it("duplicate event (ON CONFLICT DO NOTHING) → rowCount=0 → no score update", async () => { + it('duplicate event (ON CONFLICT DO NOTHING) → rowCount=0 → no score update', async () => { const mockClient: MockClient = { query: jest.fn().mockResolvedValue({ rowCount: 0, rows: [] } as never), }; stubWithTransaction(mockClient); const result = await (makeIndexer().ingestRawEvents([ - makeRawRepaidEvent("dup-event"), + makeRawRepaidEvent('dup-event'), ]) as Promise<{ insertedCount: number }>); expect(result.insertedCount).toBe(0); expect(mockUpdateUserScoresBulk).not.toHaveBeenCalled(); }); - it("aggregates score deltas for multiple events in a single bulk call", async () => { + it('aggregates score deltas for multiple events in a single bulk call', async () => { // Two LoanRepaid events for the same borrower should sum their deltas - const event1 = makeRawRepaidEvent("evt-a"); - const event2 = makeRawRepaidEvent("evt-b"); + const event1 = makeRawRepaidEvent('evt-a'); + const event2 = makeRawRepaidEvent('evt-b'); let callCount = 0; const mockClient: MockClient = { @@ -501,56 +462,49 @@ describe("EventIndexer – transaction atomicity via ingestRawEvents", () => { // Should be called once (bulk) not twice expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); - const [updates] = mockUpdateUserScoresBulk.mock.calls[0] as [ - Map, - ]; + const [updates] = mockUpdateUserScoresBulk.mock.calls[0] as [Map]; // repaymentDelta: 10, two events → 20 - expect(updates.get("addr")).toBe(20); + expect(updates.get('addr')).toBe(20); }); it("withTransaction is called — not the legacy query('BEGIN') approach", async () => { - const mockQuery = (await import("../../db/connection.js")) - .query as jest.Mock; + const mockQuery = (await import('../../db/connection.js')).query as jest.Mock; const mockClient: MockClient = { query: jest.fn().mockResolvedValue({ rowCount: 0, rows: [] } as never), }; stubWithTransaction(mockClient); - await (makeIndexer().ingestRawEvents([ - makeRawRepaidEvent(), - ]) as Promise); + await (makeIndexer().ingestRawEvents([makeRawRepaidEvent()]) as Promise); // The pool-level query() should NOT have been called with 'BEGIN' - const beginCalls = mockQuery.mock.calls.filter(([sql]) => sql === "BEGIN"); + const beginCalls = mockQuery.mock.calls.filter(([sql]) => sql === 'BEGIN'); expect(beginCalls).toHaveLength(0); // withTransaction is the entry point instead expect(mockWithTransaction).toHaveBeenCalledTimes(1); }); - it("LoanApprv: inserts audit_logs row with actor=admin, action=loan_approved", async () => { + it('LoanApprv: inserts audit_logs row with actor=admin, action=loan_approved', async () => { const auditInsertCalls: unknown[][] = []; const mockClient: MockClient = { - query: jest - .fn() - .mockImplementation(async (sql: string, params: unknown[]) => { - if (sql.includes("INSERT INTO loan_events")) { - return { rowCount: 1, rows: [{ event_id: "apprv-001" }] }; - } - if (sql.includes("INSERT INTO audit_logs")) { - auditInsertCalls.push(params); - return { rowCount: 1, rows: [] }; - } - return { rowCount: 0, rows: [] }; - }), + query: jest.fn().mockImplementation(async (sql: string, params: unknown[]) => { + if (sql.includes('INSERT INTO loan_events')) { + return { rowCount: 1, rows: [{ event_id: 'apprv-001' }] }; + } + if (sql.includes('INSERT INTO audit_logs')) { + auditInsertCalls.push(params); + return { rowCount: 1, rows: [] }; + } + return { rowCount: 0, rows: [] }; + }), }; stubWithTransaction(mockClient); - const result = await (makeIndexer().ingestRawEvents([ - makeRawLoanApprvEvent(), - ]) as Promise<{ insertedCount: number }>); + const result = await (makeIndexer().ingestRawEvents([makeRawLoanApprvEvent()]) as Promise<{ + insertedCount: number; + }>); // Event must be counted as inserted expect(result.insertedCount).toBe(1); @@ -566,26 +520,26 @@ describe("EventIndexer – transaction atomicity via ingestRawEvents", () => { ]; // actor = admin address from topic[1] - expect(actor).toBe("GADMIN123"); + expect(actor).toBe('GADMIN123'); // action = 'loan_approved' - expect(action).toBe("loan_approved"); + expect(action).toBe('loan_approved'); // target = 'loan:' - expect(target).toBe("loan:42"); + expect(target).toBe('loan:42'); // payload JSON must contain loanId, borrower, txHash const parsed = JSON.parse(payload); expect(parsed.loanId).toBe(42); - expect(parsed.borrower).toBe("GBORROWER123"); - expect(parsed.txHash).toBe("txhash-apprv-001"); + expect(parsed.borrower).toBe('GBORROWER123'); + expect(parsed.txHash).toBe('txhash-apprv-001'); }); - it("persists admin config events into audit_logs", async () => { + it('persists admin config events into audit_logs', async () => { const mockClient: MockClient = { query: jest.fn().mockImplementation(async (sql: string) => { - if (sql.includes("INSERT INTO loan_events")) { - return { rowCount: 1, rows: [{ event_id: "admin-evt-001" }] }; + if (sql.includes('INSERT INTO loan_events')) { + return { rowCount: 1, rows: [{ event_id: 'admin-evt-001' }] }; } - if (sql.includes("INSERT INTO audit_logs")) { + if (sql.includes('INSERT INTO audit_logs')) { return { rowCount: 1, rows: [] }; } return { rowCount: 0, rows: [] }; @@ -593,29 +547,27 @@ describe("EventIndexer – transaction atomicity via ingestRawEvents", () => { }; stubWithTransaction(mockClient); - const result = await (makeIndexer().ingestRawEvents([ - makeRawAdminConfigEvent(), - ]) as Promise<{ insertedCount: number }>); + const result = await (makeIndexer().ingestRawEvents([makeRawAdminConfigEvent()]) as Promise<{ + insertedCount: number; + }>); expect(result.insertedCount).toBe(1); expect( - mockClient.query.mock.calls.some(([sql]) => - String(sql).includes("INSERT INTO audit_logs"), - ), + mockClient.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO audit_logs')), ).toBe(true); }); - it("LoanLiquidated: creates a loan_liquidated notification for the borrower with refund info", async () => { + it('LoanLiquidated: creates a loan_liquidated notification for the borrower with refund info', async () => { const mockClient: MockClient = { query: jest.fn().mockResolvedValue({ rowCount: 1, - rows: [{ event_id: "liq-001" }], + rows: [{ event_id: 'liq-001' }], } as never), }; stubWithTransaction(mockClient); await (makeIndexer().ingestRawEvents([ - makeRawLoanLiquidatedEvent("liq-001"), + makeRawLoanLiquidatedEvent('liq-001'), ]) as Promise); expect(mockNotificationCreate).toHaveBeenCalledTimes(1); @@ -628,11 +580,11 @@ describe("EventIndexer – transaction atomicity via ingestRawEvents", () => { loanId: number; }; - expect(call.userId).toBe("GBORROWER456"); - expect(call.type).toBe("loan_liquidated"); - expect(call.title).toBe("Loan Liquidated"); + expect(call.userId).toBe('GBORROWER456'); + expect(call.type).toBe('loan_liquidated'); + expect(call.title).toBe('Loan Liquidated'); expect(call.loanId).toBe(7); - expect(call.message).toContain("Loan #7"); - expect(call.message).toContain("200"); + expect(call.message).toContain('Loan #7'); + expect(call.message).toContain('200'); }); }); diff --git a/backend/src/services/__tests__/loanEndpoints.test.ts b/backend/src/services/__tests__/loanEndpoints.test.ts index 1a482669..ccc3925f 100644 --- a/backend/src/services/__tests__/loanEndpoints.test.ts +++ b/backend/src/services/__tests__/loanEndpoints.test.ts @@ -1,6 +1,6 @@ -import request from "supertest"; -import { jest } from "@jest/globals"; -import { Keypair } from "@stellar/stellar-sdk"; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { Keypair } from '@stellar/stellar-sdk'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; @@ -8,15 +8,15 @@ const BORROWER = Keypair.random().publicKey(); const ADMIN = Keypair.random().publicKey(); // Configure auth before any module that reads these at import/sign time. -process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.JWT_SECRET = 'test-jwt-secret-min-32-chars-long!!'; process.env.ADMIN_WALLETS = ADMIN; // Loan fixtures keyed by the id used in the request path. PENDING satisfies // both the cancel (PENDING|OPEN) and reject (PENDING) guards. const loans: Record = { - "loan-123": { status: "PENDING", address: BORROWER }, - "completed-loan": { status: "COMPLETED", address: BORROWER }, - "loan-1": { status: "PENDING", address: BORROWER }, + 'loan-123': { status: 'PENDING', address: BORROWER }, + 'completed-loan': { status: 'COMPLETED', address: BORROWER }, + 'loan-1': { status: 'PENDING', address: BORROWER }, }; const mockQuery: jest.MockedFunction< @@ -37,7 +37,7 @@ const mockQuery: jest.MockedFunction< return { rows: [] }; }); -jest.unstable_mockModule("../../db/connection.js", () => ({ +jest.unstable_mockModule('../../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), @@ -46,12 +46,12 @@ jest.unstable_mockModule("../../db/connection.js", () => ({ })); // Keep Redis out of the test. -jest.unstable_mockModule("../cacheService.js", () => ({ +jest.unstable_mockModule('../cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), delete: jest.fn<() => Promise>().mockResolvedValue(undefined), - ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + ping: jest.fn<() => Promise>().mockResolvedValue('ok'), }, })); @@ -59,64 +59,64 @@ jest.unstable_mockModule("../cacheService.js", () => ({ const mockBuildCancelLoanTx = jest .fn<(borrower: string, loanId: string) => Promise>() .mockResolvedValue({ - unsignedTxXdr: "AAAAcancel", - networkPassphrase: "Test", + unsignedTxXdr: 'AAAAcancel', + networkPassphrase: 'Test', }); const mockBuildRejectLoanTx = jest .fn<(admin: string, loanId: string, reason: string) => Promise>() .mockResolvedValue({ - unsignedTxXdr: "AAAAreject", - networkPassphrase: "Test", + unsignedTxXdr: 'AAAAreject', + networkPassphrase: 'Test', }); -jest.unstable_mockModule("../sorobanService.js", () => ({ +jest.unstable_mockModule('../sorobanService.js', () => ({ sorobanService: { buildCancelLoanTx: mockBuildCancelLoanTx, buildRejectLoanTx: mockBuildRejectLoanTx, }, })); -const { generateJwtToken } = await import("../authService.js"); -const { default: app } = await import("../../app.js"); +const { generateJwtToken } = await import('../authService.js'); +const { default: app } = await import('../../app.js'); const borrowerAuth = `Bearer ${generateJwtToken(BORROWER)}`; const adminAuth = `Bearer ${generateJwtToken(ADMIN)}`; -describe("POST /api/loans/:loanId/build-cancel", () => { - it("should build cancel transaction", async () => { +describe('POST /api/loans/:loanId/build-cancel', () => { + it('should build cancel transaction', async () => { const response = await request(app) - .post("/api/loans/loan-123/build-cancel") - .set("Authorization", borrowerAuth); + .post('/api/loans/loan-123/build-cancel') + .set('Authorization', borrowerAuth); expect(response.status).toBe(200); expect(response.body.transaction).toBeDefined(); }); - it("should reject non-cancellable loans", async () => { + it('should reject non-cancellable loans', async () => { const response = await request(app) - .post("/api/loans/completed-loan/build-cancel") - .set("Authorization", borrowerAuth); + .post('/api/loans/completed-loan/build-cancel') + .set('Authorization', borrowerAuth); expect(response.status).toBe(400); }); }); -describe("POST /api/admin/loans/:loanId/build-reject", () => { - it("should build reject transaction", async () => { +describe('POST /api/admin/loans/:loanId/build-reject', () => { + it('should build reject transaction', async () => { const response = await request(app) - .post("/api/admin/loans/loan-123/build-reject") - .set("Authorization", adminAuth) - .send({ reason: "Insufficient collateral" }); + .post('/api/admin/loans/loan-123/build-reject') + .set('Authorization', adminAuth) + .send({ reason: 'Insufficient collateral' }); expect(response.status).toBe(200); expect(response.body.transaction).toBeDefined(); }); - it("should fail if reason too short", async () => { + it('should fail if reason too short', async () => { const response = await request(app) - .post("/api/admin/loans/loan-1/build-reject") - .set("Authorization", adminAuth) - .send({ reason: "bad" }); + .post('/api/admin/loans/loan-1/build-reject') + .set('Authorization', adminAuth) + .send({ reason: 'bad' }); expect(response.status).toBe(400); }); diff --git a/backend/src/services/__tests__/notificationService.test.ts b/backend/src/services/__tests__/notificationService.test.ts index 9da7c80b..c4b060d3 100644 --- a/backend/src/services/__tests__/notificationService.test.ts +++ b/backend/src/services/__tests__/notificationService.test.ts @@ -1,43 +1,42 @@ import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; type QueryResult = { rows: Record[]; rowCount: number }; -const mockQuery = - jest.fn<(sql: string, params?: unknown[]) => Promise>(); +const mockQuery = jest.fn<(sql: string, params?: unknown[]) => Promise>(); -jest.unstable_mockModule("../../db/connection.js", () => ({ +jest.unstable_mockModule('../../db/connection.js', () => ({ query: mockQuery, })); -jest.unstable_mockModule("twilio", () => ({ +jest.unstable_mockModule('twilio', () => ({ default: jest.fn(() => ({ messages: { create: jest.fn() } })), })); -jest.unstable_mockModule("@sendgrid/mail", () => ({ +jest.unstable_mockModule('@sendgrid/mail', () => ({ default: { setApiKey: jest.fn(), send: jest.fn() }, })); -const { notificationService } = await import("../notificationService.js"); +const { notificationService } = await import('../notificationService.js'); -describe("notificationService", () => { +describe('notificationService', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe("createNotification", () => { - it("sets actionUrl from loanId when not explicitly provided", async () => { + describe('createNotification', () => { + it('sets actionUrl from loanId when not explicitly provided', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 1, - user_id: "user1", - type: "loan_approved", - title: "Loan Approved", - message: "Your loan has been approved", + user_id: 'user1', + type: 'loan_approved', + title: 'Loan Approved', + message: 'Your loan has been approved', loan_id: 42, - action_url: "/loans/42", + action_url: '/loans/42', read: false, - status: "unread", - created_at: new Date("2026-05-28T12:00:00.000Z"), + status: 'unread', + created_at: new Date('2026-05-28T12:00:00.000Z'), }, ], rowCount: 1, @@ -56,32 +55,32 @@ describe("notificationService", () => { }); const notification = await notificationService.createNotification({ - userId: "user1", - type: "loan_approved", - title: "Loan Approved", - message: "Your loan has been approved", + userId: 'user1', + type: 'loan_approved', + title: 'Loan Approved', + message: 'Your loan has been approved', loanId: 42, }); - expect(notification.actionUrl).toBe("/loans/42"); + expect(notification.actionUrl).toBe('/loans/42'); const insertCall = mockQuery.mock.calls[0] as [string, unknown[]]; - expect(insertCall[1]).toContain("/loans/42"); + expect(insertCall[1]).toContain('/loans/42'); }); - it("uses explicit actionUrl over loanId when provided", async () => { + it('uses explicit actionUrl over loanId when provided', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 2, - user_id: "user2", - type: "repayment_confirmed", - title: "Remittance Sent", - message: "Remittance submitted", + user_id: 'user2', + type: 'repayment_confirmed', + title: 'Remittance Sent', + message: 'Remittance submitted', loan_id: null, - action_url: "/remittances/99", + action_url: '/remittances/99', read: false, - status: "unread", - created_at: new Date("2026-05-28T12:00:00.000Z"), + status: 'unread', + created_at: new Date('2026-05-28T12:00:00.000Z'), }, ], rowCount: 1, @@ -100,30 +99,30 @@ describe("notificationService", () => { }); const notification = await notificationService.createNotification({ - userId: "user2", - type: "repayment_confirmed", - title: "Remittance Sent", - message: "Remittance submitted", - actionUrl: "/remittances/99", + userId: 'user2', + type: 'repayment_confirmed', + title: 'Remittance Sent', + message: 'Remittance submitted', + actionUrl: '/remittances/99', }); - expect(notification.actionUrl).toBe("/remittances/99"); + expect(notification.actionUrl).toBe('/remittances/99'); }); - it("returns null actionUrl when neither loanId nor actionUrl provided", async () => { + it('returns null actionUrl when neither loanId nor actionUrl provided', async () => { mockQuery.mockResolvedValueOnce({ rows: [ { id: 3, - user_id: "user3", - type: "score_changed", - title: "Score Changed", - message: "Your score changed", + user_id: 'user3', + type: 'score_changed', + title: 'Score Changed', + message: 'Your score changed', loan_id: null, action_url: null, read: false, - status: "unread", - created_at: new Date("2026-05-28T12:00:00.000Z"), + status: 'unread', + created_at: new Date('2026-05-28T12:00:00.000Z'), }, ], rowCount: 1, @@ -142,10 +141,10 @@ describe("notificationService", () => { }); const notification = await notificationService.createNotification({ - userId: "user3", - type: "score_changed", - title: "Score Changed", - message: "Your score changed", + userId: 'user3', + type: 'score_changed', + title: 'Score Changed', + message: 'Your score changed', }); expect(notification.actionUrl).toBeUndefined(); diff --git a/backend/src/services/__tests__/rateLimitService.test.ts b/backend/src/services/__tests__/rateLimitService.test.ts index 1644613c..f3cb9cc5 100644 --- a/backend/src/services/__tests__/rateLimitService.test.ts +++ b/backend/src/services/__tests__/rateLimitService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; const mockConnect = jest.fn<() => Promise>(); const mockOn = jest.fn(); @@ -8,7 +8,7 @@ const mockTtl = jest.fn<(key: string) => Promise>(); const mockGet = jest.fn<(key: string) => Promise>(); const mockDel = jest.fn<(key: string) => Promise>(); -jest.unstable_mockModule("redis", () => ({ +jest.unstable_mockModule('redis', () => ({ createClient: () => ({ connect: mockConnect, on: mockOn, @@ -20,11 +20,9 @@ jest.unstable_mockModule("redis", () => ({ }), })); -const { rateLimitService, SCORE_UPDATE_RATE_LIMIT } = await import( - "../rateLimitService.js" -); +const { rateLimitService, SCORE_UPDATE_RATE_LIMIT } = await import('../rateLimitService.js'); -describe("rateLimitService", () => { +describe('rateLimitService', () => { beforeEach(() => { jest.clearAllMocks(); mockConnect.mockResolvedValue(undefined); @@ -34,35 +32,29 @@ describe("rateLimitService", () => { mockDel.mockResolvedValue(1); }); - it("allows the first request and creates the rate-limit window", async () => { + it('allows the first request and creates the rate-limit window', async () => { mockIncr.mockResolvedValueOnce(1); - const result = await rateLimitService.checkRateLimit( - "user123", - SCORE_UPDATE_RATE_LIMIT, - ); + const result = await rateLimitService.checkRateLimit('user123', SCORE_UPDATE_RATE_LIMIT); expect(result.allowed).toBe(true); expect(result.remaining).toBe(4); expect(result.currentCount).toBe(1); - expect(mockIncr).toHaveBeenCalledWith("rate_limit:user123"); - expect(mockExpire).toHaveBeenCalledWith("rate_limit:user123", 86400); + expect(mockIncr).toHaveBeenCalledWith('rate_limit:user123'); + expect(mockExpire).toHaveBeenCalledWith('rate_limit:user123', 86400); }); - it("blocks requests once the atomic counter exceeds the limit", async () => { + it('blocks requests once the atomic counter exceeds the limit', async () => { mockIncr.mockResolvedValueOnce(6); - const result = await rateLimitService.checkRateLimit( - "user123", - SCORE_UPDATE_RATE_LIMIT, - ); + const result = await rateLimitService.checkRateLimit('user123', SCORE_UPDATE_RATE_LIMIT); expect(result.allowed).toBe(false); expect(result.remaining).toBe(0); expect(result.currentCount).toBe(6); }); - it("admits at most maxRequests under concurrent requests", async () => { + it('admits at most maxRequests under concurrent requests', async () => { let counter = 0; mockIncr.mockImplementation(async () => { counter += 1; @@ -71,7 +63,7 @@ describe("rateLimitService", () => { const results = await Promise.all( Array.from({ length: 10 }, () => - rateLimitService.checkRateLimit("score:user1", { + rateLimitService.checkRateLimit('score:user1', { maxRequests: 5, windowSeconds: 60, }), @@ -83,46 +75,37 @@ describe("rateLimitService", () => { expect(mockIncr).toHaveBeenCalledTimes(10); }); - it("preserves fail-open behavior when Redis is unavailable", async () => { - mockIncr.mockRejectedValueOnce(new Error("Redis connection failed")); + it('preserves fail-open behavior when Redis is unavailable', async () => { + mockIncr.mockRejectedValueOnce(new Error('Redis connection failed')); - const result = await rateLimitService.checkRateLimit( - "user123", - SCORE_UPDATE_RATE_LIMIT, - ); + const result = await rateLimitService.checkRateLimit('user123', SCORE_UPDATE_RATE_LIMIT); expect(result.allowed).toBe(true); expect(result.remaining).toBe(4); expect(result.currentCount).toBe(1); }); - it("resets the rate limit counter", async () => { - await rateLimitService.resetRateLimit("user123"); + it('resets the rate limit counter', async () => { + await rateLimitService.resetRateLimit('user123'); - expect(mockDel).toHaveBeenCalledWith("rate_limit:user123"); + expect(mockDel).toHaveBeenCalledWith('rate_limit:user123'); }); - it("returns current status without incrementing", async () => { - mockGet.mockResolvedValueOnce("2"); + it('returns current status without incrementing', async () => { + mockGet.mockResolvedValueOnce('2'); mockTtl.mockResolvedValueOnce(120); - const result = await rateLimitService.getRateLimitStatus( - "user123", - SCORE_UPDATE_RATE_LIMIT, - ); + const result = await rateLimitService.getRateLimitStatus('user123', SCORE_UPDATE_RATE_LIMIT); expect(result.allowed).toBe(true); expect(result.remaining).toBe(3); expect(mockIncr).not.toHaveBeenCalled(); }); - it("returns default status for new identifiers", async () => { + it('returns default status for new identifiers', async () => { mockGet.mockResolvedValueOnce(null); - const result = await rateLimitService.getRateLimitStatus( - "user123", - SCORE_UPDATE_RATE_LIMIT, - ); + const result = await rateLimitService.getRateLimitStatus('user123', SCORE_UPDATE_RATE_LIMIT); expect(result.allowed).toBe(true); expect(result.remaining).toBe(5); diff --git a/backend/src/services/__tests__/scoreDecayService.test.ts b/backend/src/services/__tests__/scoreDecayService.test.ts index 60368279..2dfbb4a3 100644 --- a/backend/src/services/__tests__/scoreDecayService.test.ts +++ b/backend/src/services/__tests__/scoreDecayService.test.ts @@ -1,62 +1,57 @@ -import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; type QueryResult = { rows: unknown[]; rowCount: number }; -const mockQuery = - jest.fn<(sql: string, params?: unknown[]) => Promise>(); +const mockQuery = jest.fn<(sql: string, params?: unknown[]) => Promise>(); -jest.unstable_mockModule("../../db/connection.js", () => ({ +jest.unstable_mockModule('../../db/connection.js', () => ({ query: mockQuery, })); -const { getInactiveBorrowers, applyScoreDecay } = await import( - "../scoreDecayService.js" -); +const { getInactiveBorrowers, applyScoreDecay } = await import('../scoreDecayService.js'); -describe("scoreDecayService", () => { +describe('scoreDecayService', () => { beforeEach(() => { jest.clearAllMocks(); mockQuery.mockResolvedValue({ rows: [], rowCount: 1 }); }); - describe("getInactiveBorrowers", () => { - it("selects inactive borrowers from the canonical scores table", async () => { + describe('getInactiveBorrowers', () => { + it('selects inactive borrowers from the canonical scores table', async () => { mockQuery.mockResolvedValueOnce({ - rows: [{ borrower: "user1", score: 700, last_repayment: null }], + rows: [{ borrower: 'user1', score: 700, last_repayment: null }], rowCount: 1, }); const borrowers = await getInactiveBorrowers(); - expect(borrowers).toEqual([ - { borrower: "user1", score: 700, last_repayment: null }, - ]); + expect(borrowers).toEqual([{ borrower: 'user1', score: 700, last_repayment: null }]); const sql = mockQuery.mock.calls[0]![0]; - expect(sql).toContain("FROM scores s"); - expect(sql).toContain("s.borrower"); - expect(sql).not.toContain("FROM borrowers"); + expect(sql).toContain('FROM scores s'); + expect(sql).toContain('s.borrower'); + expect(sql).not.toContain('FROM borrowers'); }); }); - describe("applyScoreDecay", () => { - it("decays inactive borrower with no repayment by configured amount", async () => { - const borrower = { borrower: "user1", score: 700, last_repayment: null }; + describe('applyScoreDecay', () => { + it('decays inactive borrower with no repayment by configured amount', async () => { + const borrower = { borrower: 'user1', score: 700, last_repayment: null }; const newScore = await applyScoreDecay(borrower); // No last_repayment => monthsInactive = 1 => decay = 1 * 5 = 5 expect(newScore).toBe(695); expect(mockQuery).toHaveBeenCalledWith( - "UPDATE scores SET score = $1, updated_at = CURRENT_TIMESTAMP WHERE borrower = $2", - [695, "user1"], + 'UPDATE scores SET score = $1, updated_at = CURRENT_TIMESTAMP WHERE borrower = $2', + [695, 'user1'], ); }); - it("decays borrower inactive for multiple months", async () => { + it('decays borrower inactive for multiple months', async () => { // 90 days = exactly 3 30-day months const ninetyDaysAgo = new Date(); ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90); const borrower = { - borrower: "user2", + borrower: 'user2', score: 700, last_repayment: ninetyDaysAgo.toISOString(), }; @@ -66,12 +61,12 @@ describe("scoreDecayService", () => { expect(newScore).toBe(685); }); - it("applies minimum decay of one month even with recent activity", async () => { + it('applies minimum decay of one month even with recent activity', async () => { const yesterday = new Date(); yesterday.setUTCDate(yesterday.getUTCDate() - 1); const borrower = { - borrower: "user3", + borrower: 'user3', score: 700, last_repayment: yesterday.toISOString(), }; @@ -81,24 +76,24 @@ describe("scoreDecayService", () => { expect(newScore).toBe(695); }); - it("floors score at minimum score", async () => { - const borrower = { borrower: "user4", score: 304, last_repayment: null }; + it('floors score at minimum score', async () => { + const borrower = { borrower: 'user4', score: 304, last_repayment: null }; const newScore = await applyScoreDecay(borrower); // 304 - 5 = 299, floored to 300 expect(newScore).toBe(300); }); - it("never drops score below minimum even if already below", async () => { - const borrower = { borrower: "user5", score: 200, last_repayment: null }; + it('never drops score below minimum even if already below', async () => { + const borrower = { borrower: 'user5', score: 200, last_repayment: null }; const newScore = await applyScoreDecay(borrower); // max(300, 200 - 5) = 300 expect(newScore).toBe(300); }); - it("is idempotent for identical borrower input", async () => { - const borrower = { borrower: "user6", score: 700, last_repayment: null }; + it('is idempotent for identical borrower input', async () => { + const borrower = { borrower: 'user6', score: 700, last_repayment: null }; const first = await applyScoreDecay(borrower); const second = await applyScoreDecay(borrower); diff --git a/backend/src/services/__tests__/scoresService.test.ts b/backend/src/services/__tests__/scoresService.test.ts index 9500e736..c7f7e303 100644 --- a/backend/src/services/__tests__/scoresService.test.ts +++ b/backend/src/services/__tests__/scoresService.test.ts @@ -8,20 +8,10 @@ * - Propagates errors correctly */ -import { - jest, - describe, - it, - expect, - beforeAll, - beforeEach, -} from "@jest/globals"; -import type { PoolClient } from "../../db/connection.js"; - -type QueryFn = ( - sql: string, - params?: unknown[], -) => Promise<{ rows: never[]; rowCount: number }>; +import { jest, describe, it, expect, beforeAll, beforeEach } from '@jest/globals'; +import type { PoolClient } from '../../db/connection.js'; + +type QueryFn = (sql: string, params?: unknown[]) => Promise<{ rows: never[]; rowCount: number }>; type DeleteFn = (key: string) => Promise; type GetFn = (key: string) => Promise; type SetFn = (key: string, value: unknown) => Promise; @@ -32,10 +22,7 @@ type MockClient = { query: jest.MockedFunction; }; -let updateUserScoresBulk: ( - updates: Map, - client?: PoolClient, -) => Promise; +let updateUserScoresBulk: (updates: Map, client?: PoolClient) => Promise; let mockQuery: jest.MockedFunction; let mockLoggerInfo: jest.Mock; let mockLoggerError: jest.Mock; @@ -52,19 +39,13 @@ beforeAll(async () => { })) as jest.MockedFunction; mockLoggerInfo = jest.fn(); mockLoggerError = jest.fn(); - mockCacheDelete = jest.fn( - async () => undefined, - ) as jest.MockedFunction; + mockCacheDelete = jest.fn(async () => undefined) as jest.MockedFunction; mockCacheGet = jest.fn(async () => null) as jest.MockedFunction; mockCacheSet = jest.fn(async () => undefined) as jest.MockedFunction; - mockCacheSetNotExists = jest.fn( - async () => true, - ) as jest.MockedFunction; - mockCacheClose = jest.fn( - async () => undefined, - ) as jest.MockedFunction; - - jest.unstable_mockModule("../cacheService.js", () => ({ + mockCacheSetNotExists = jest.fn(async () => true) as jest.MockedFunction; + mockCacheClose = jest.fn(async () => undefined) as jest.MockedFunction; + + jest.unstable_mockModule('../cacheService.js', () => ({ cacheService: { get: jest.fn<() => Promise>().mockResolvedValue(null), set: jest.fn<() => Promise>().mockResolvedValue(undefined), @@ -72,14 +53,14 @@ beforeAll(async () => { }, })); - jest.unstable_mockModule("../../db/connection.js", () => ({ + jest.unstable_mockModule('../../db/connection.js', () => ({ query: mockQuery, getClient: jest.fn(), withTransaction: jest.fn(), TRANSIENT_ERROR_CODES: new Set(), })); - jest.unstable_mockModule("../../utils/logger.js", () => { + jest.unstable_mockModule('../../utils/logger.js', () => { const mockLogger = { info: mockLoggerInfo, error: mockLoggerError, @@ -91,7 +72,7 @@ beforeAll(async () => { return { default: mockLogger }; }); - jest.unstable_mockModule("../cacheService.js", () => ({ + jest.unstable_mockModule('../cacheService.js', () => ({ cacheService: { delete: mockCacheDelete, get: mockCacheGet, @@ -101,7 +82,7 @@ beforeAll(async () => { }, })); - const mod = await import("../scoresService.js"); + const mod = await import('../scoresService.js'); updateUserScoresBulk = mod.updateUserScoresBulk; }); @@ -112,33 +93,33 @@ beforeEach(() => { // --------------------------------------------------------------------------- -describe("updateUserScoresBulk", () => { - describe("standalone (no client)", () => { - it("is a noop for an empty map", async () => { +describe('updateUserScoresBulk', () => { + describe('standalone (no client)', () => { + it('is a noop for an empty map', async () => { await updateUserScoresBulk(new Map()); expect(mockQuery).not.toHaveBeenCalled(); }); - it("is a noop when all user IDs are empty strings", async () => { - await updateUserScoresBulk(new Map([["", 50]])); + it('is a noop when all user IDs are empty strings', async () => { + await updateUserScoresBulk(new Map([['', 50]])); expect(mockQuery).not.toHaveBeenCalled(); }); - it.skip("calls pool query with correct placeholders for a single user", async () => { - await updateUserScoresBulk(new Map([["user1", 10]])); + it.skip('calls pool query with correct placeholders for a single user', async () => { + await updateUserScoresBulk(new Map([['user1', 10]])); expect(mockQuery).toHaveBeenCalledTimes(1); const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("INSERT INTO scores"); - expect(sql).toContain("ON CONFLICT (user_id)"); - expect(params).toEqual(["user1", 10]); + expect(sql).toContain('INSERT INTO scores'); + expect(sql).toContain('ON CONFLICT (user_id)'); + expect(params).toEqual(['user1', 10]); }, 20000); - it.skip("calls pool query for multiple users in a single statement", async () => { + it.skip('calls pool query for multiple users in a single statement', async () => { const updates = new Map([ - ["alice", 15], - ["bob", -20], + ['alice', 15], + ['bob', -20], ]); await updateUserScoresBulk(updates); @@ -146,8 +127,8 @@ describe("updateUserScoresBulk", () => { const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; // Expect both users in params - expect(params).toContain("alice"); - expect(params).toContain("bob"); + expect(params).toContain('alice'); + expect(params).toContain('bob'); expect(params).toContain(15); expect(params).toContain(-20); @@ -156,74 +137,59 @@ describe("updateUserScoresBulk", () => { expect(sql.match(/\$3/g)).toBeTruthy(); }); - it("logs success after updating", async () => { - await updateUserScoresBulk(new Map([["user1", 5]])); - expect(mockLoggerInfo).toHaveBeenCalledWith( - "Applied bulk user score updates", - { updatedCount: 1 }, - ); + it('logs success after updating', async () => { + await updateUserScoresBulk(new Map([['user1', 5]])); + expect(mockLoggerInfo).toHaveBeenCalledWith('Applied bulk user score updates', { + updatedCount: 1, + }); }); - it("propagates db errors and logs them", async () => { - mockQuery.mockRejectedValueOnce(new Error("db error")); + it('propagates db errors and logs them', async () => { + mockQuery.mockRejectedValueOnce(new Error('db error')); - await expect( - updateUserScoresBulk(new Map([["user1", 5]])), - ).rejects.toThrow("db error"); + await expect(updateUserScoresBulk(new Map([['user1', 5]]))).rejects.toThrow('db error'); expect(mockLoggerError).toHaveBeenCalledWith( - "Failed to apply bulk user score updates", + 'Failed to apply bulk user score updates', expect.objectContaining({ error: expect.any(Error) }), ); }); }); - describe("with pinned client (inside transaction)", () => { - it("uses client.query instead of pool query", async () => { + describe('with pinned client (inside transaction)', () => { + it('uses client.query instead of pool query', async () => { const mockClient = { query: jest.fn(async () => ({ rows: [], rowCount: 1 })), } as MockClient; - await updateUserScoresBulk( - new Map([["user1", 10]]), - mockClient as unknown as PoolClient, - ); + await updateUserScoresBulk(new Map([['user1', 10]]), mockClient as unknown as PoolClient); // Pool-level query must NOT be called expect(mockQuery).not.toHaveBeenCalled(); // Client query IS called expect(mockClient.query).toHaveBeenCalledTimes(1); - const [sql, params] = mockClient.query.mock.calls[0] as unknown as [ - string, - unknown[], - ]; - expect(sql).toContain("INSERT INTO scores"); - expect(params).toContain("user1"); + const [sql, params] = mockClient.query.mock.calls[0] as unknown as [string, unknown[]]; + expect(sql).toContain('INSERT INTO scores'); + expect(params).toContain('user1'); }); - it("propagates errors from client.query", async () => { + it('propagates errors from client.query', async () => { const mockClient = { query: jest.fn(async () => { - throw new Error("client fail"); + throw new Error('client fail'); }), } as MockClient; await expect( - updateUserScoresBulk( - new Map([["user1", 5]]), - mockClient as unknown as PoolClient, - ), - ).rejects.toThrow("client fail"); + updateUserScoresBulk(new Map([['user1', 5]]), mockClient as unknown as PoolClient), + ).rejects.toThrow('client fail'); }); - it("is a noop for empty map even with a client", async () => { + it('is a noop for empty map even with a client', async () => { const mockClient = { query: jest.fn() } as MockClient; - await updateUserScoresBulk( - new Map(), - mockClient as unknown as PoolClient, - ); + await updateUserScoresBulk(new Map(), mockClient as unknown as PoolClient); expect(mockClient.query).not.toHaveBeenCalled(); expect(mockQuery).not.toHaveBeenCalled(); diff --git a/backend/src/services/__tests__/webhookRetryProcessor.test.ts b/backend/src/services/__tests__/webhookRetryProcessor.test.ts index 236d4de6..95abf338 100644 --- a/backend/src/services/__tests__/webhookRetryProcessor.test.ts +++ b/backend/src/services/__tests__/webhookRetryProcessor.test.ts @@ -1,11 +1,4 @@ -import { - jest, - describe, - it, - expect, - beforeEach, - afterEach, -} from "@jest/globals"; +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; @@ -13,26 +6,25 @@ const mockQuery: jest.MockedFunction< (text: string, params?: unknown[]) => Promise > = jest.fn(); -jest.unstable_mockModule("../../db/connection.js", () => ({ +jest.unstable_mockModule('../../db/connection.js', () => ({ default: { query: mockQuery }, query: mockQuery, getClient: jest.fn(), closePool: jest.fn(), })); -jest.unstable_mockModule("../../middleware/metrics.js", () => ({ +jest.unstable_mockModule('../../middleware/metrics.js', () => ({ refreshWebhookRetryQueueDepth: jest.fn(), })); -jest.unstable_mockModule("../jobMetricsService.js", () => ({ +jest.unstable_mockModule('../jobMetricsService.js', () => ({ jobMetricsService: { recordSuccess: jest.fn(), recordFailure: jest.fn(), }, })); -const { WebhookService, getRetryDelayMs } = - await import("../webhookService.js"); +const { WebhookService, getRetryDelayMs } = await import('../webhookService.js'); const MAX_RETRY_ATTEMPTS = 4; @@ -40,17 +32,17 @@ function deliveryRow(overrides: Record = {}) { return { id: 1, subscription_id: 1, - callback_url: "https://hook.example.com/callback", + callback_url: 'https://hook.example.com/callback', secret: null, - event_id: "evt-001", - event_type: "LoanApproved", - payload: { eventId: "evt-001", eventType: "LoanApproved", loanId: 42 }, + event_id: 'evt-001', + event_type: 'LoanApproved', + payload: { eventId: 'evt-001', eventType: 'LoanApproved', loanId: 42 }, attempt_count: 0, ...overrides, }; } -describe("WebhookRetryProcessor", () => { +describe('WebhookRetryProcessor', () => { const originalFetch = global.fetch; beforeEach(() => { @@ -62,20 +54,20 @@ describe("WebhookRetryProcessor", () => { global.fetch = originalFetch; }); - describe("processRetries", () => { - it("handles no pending deliveries gracefully", async () => { + describe('processRetries', () => { + it('handles no pending deliveries gracefully', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); await WebhookService.processRetries(); expect(mockQuery).toHaveBeenCalledTimes(1); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining("FROM webhook_deliveries"), + expect.stringContaining('FROM webhook_deliveries'), expect.any(Array), ); }); - it("retries a pending delivery successfully", async () => { + it('retries a pending delivery successfully', async () => { const fetchMock = jest.fn(async () => ({ ok: true, status: 200, @@ -91,18 +83,18 @@ describe("WebhookRetryProcessor", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - "https://hook.example.com/callback", - expect.objectContaining({ method: "POST" }), + 'https://hook.example.com/callback', + expect.objectContaining({ method: 'POST' }), ); const updateCall = mockQuery.mock.calls[1] as [string, unknown[]]; - expect(updateCall[0]).toContain("UPDATE webhook_deliveries"); + expect(updateCall[0]).toContain('UPDATE webhook_deliveries'); expect(updateCall[1]?.[0]).toBe(2); // attempt_count = 1 + 1 expect(updateCall[1]?.[1]).toBe(200); // last_status_code expect(updateCall[1]?.[2]).toBeInstanceOf(Date); // delivered_at }); - it("schedules backoff retry on failure", async () => { + it('schedules backoff retry on failure', async () => { const fetchMock = jest.fn(async () => ({ ok: false, status: 503, @@ -111,7 +103,7 @@ describe("WebhookRetryProcessor", () => { const row = deliveryRow({ attempt_count: 0 }); const now = 1_700_000_000_000; - jest.spyOn(Date, "now").mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now); mockQuery .mockResolvedValueOnce({ rows: [row] }) @@ -121,14 +113,14 @@ describe("WebhookRetryProcessor", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const updateCall = mockQuery.mock.calls[1] as [string, unknown[]]; - expect(updateCall[0]).toContain("UPDATE webhook_deliveries"); + expect(updateCall[0]).toContain('UPDATE webhook_deliveries'); expect(updateCall[1]?.[0]).toBe(1); // attempt_count expect(updateCall[1]?.[1]).toBe(503); // last_status_code - expect(updateCall[1]?.[2]).toBe("Webhook returned status 503"); + expect(updateCall[1]?.[2]).toBe('Webhook returned status 503'); expect(updateCall[1]?.[3]).toEqual(new Date(now + getRetryDelayMs(1))); // next_retry_at }); - it("sets next_retry_at with progressive backoff on multiple failures", async () => { + it('sets next_retry_at with progressive backoff on multiple failures', async () => { const fetchMock = jest.fn(async () => ({ ok: false, status: 500, @@ -136,7 +128,7 @@ describe("WebhookRetryProcessor", () => { global.fetch = fetchMock as unknown as typeof fetch; const now = 1_700_000_000_000; - jest.spyOn(Date, "now").mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now); const row = deliveryRow({ attempt_count: 2 }); mockQuery @@ -156,8 +148,8 @@ describe("WebhookRetryProcessor", () => { }); }); - describe("circuit-breaker behavior (max attempts)", () => { - it("permanently fails delivery after max retry attempts", async () => { + describe('circuit-breaker behavior (max attempts)', () => { + it('permanently fails delivery after max retry attempts', async () => { const fetchMock = jest.fn(async () => ({ ok: false, status: 500, @@ -165,7 +157,7 @@ describe("WebhookRetryProcessor", () => { global.fetch = fetchMock as unknown as typeof fetch; const now = 1_700_000_000_000; - jest.spyOn(Date, "now").mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now); // attempt_count = MAX_RETRY_ATTEMPTS - 1 means next attempt will hit the limit const row = deliveryRow({ attempt_count: MAX_RETRY_ATTEMPTS - 1 }); @@ -183,7 +175,7 @@ describe("WebhookRetryProcessor", () => { expect(updateCall[1]?.[3]).toBeNull(); }); - it("does not pick up deliveries at max attempts (circuit open)", async () => { + it('does not pick up deliveries at max attempts (circuit open)', async () => { const fetchMock = jest.fn(async () => ({ ok: true, status: 200, @@ -201,8 +193,8 @@ describe("WebhookRetryProcessor", () => { }); }); - describe("subscriber isolation", () => { - it("processes remaining deliveries when one delivery fails", async () => { + describe('subscriber isolation', () => { + it('processes remaining deliveries when one delivery fails', async () => { let callCount = 0; const fetchMock = jest.fn(async () => { callCount++; @@ -216,18 +208,18 @@ describe("WebhookRetryProcessor", () => { const row1 = deliveryRow({ id: 1, subscription_id: 1, - callback_url: "https://degraded.example.com/callback", + callback_url: 'https://degraded.example.com/callback', attempt_count: 1, }); const row2 = deliveryRow({ id: 2, subscription_id: 2, - callback_url: "https://healthy.example.com/callback", + callback_url: 'https://healthy.example.com/callback', attempt_count: 1, }); const now = 1_700_000_000_000; - jest.spyOn(Date, "now").mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now); mockQuery .mockResolvedValueOnce({ rows: [row1, row2] }) @@ -237,27 +229,23 @@ describe("WebhookRetryProcessor", () => { await WebhookService.processRetries(); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[0]?.[0]).toBe( - "https://degraded.example.com/callback", - ); - expect(fetchMock.mock.calls[1]?.[0]).toBe( - "https://healthy.example.com/callback", - ); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://degraded.example.com/callback'); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://healthy.example.com/callback'); // Both deliveries should have been processed (one failed, one succeeded) expect(mockQuery).toHaveBeenCalledTimes(3); const updateCalls = mockQuery.mock.calls.filter((call) => - (call[0] as string).includes("UPDATE"), + (call[0] as string).includes('UPDATE'), ); expect(updateCalls).toHaveLength(2); }); - it("continues processing other deliveries even after a network error on one", async () => { + it('continues processing other deliveries even after a network error on one', async () => { let callCount = 0; const fetchMock = jest.fn(async () => { callCount++; if (callCount === 1) { - throw new Error("Network timeout"); + throw new Error('Network timeout'); } return { ok: true, status: 200 }; }) as unknown as jest.MockedFunction; @@ -266,18 +254,18 @@ describe("WebhookRetryProcessor", () => { const row1 = deliveryRow({ id: 1, subscription_id: 1, - callback_url: "https://failing.example.com/callback", + callback_url: 'https://failing.example.com/callback', attempt_count: 0, }); const row2 = deliveryRow({ id: 2, subscription_id: 2, - callback_url: "https://good.example.com/callback", + callback_url: 'https://good.example.com/callback', attempt_count: 0, }); const now = 1_700_000_000_000; - jest.spyOn(Date, "now").mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now); mockQuery .mockResolvedValueOnce({ rows: [row1, row2] }) @@ -290,40 +278,40 @@ describe("WebhookRetryProcessor", () => { // Both deliveries should have been updated in DB const updateCalls = mockQuery.mock.calls.filter((call) => - (call[0] as string).includes("UPDATE"), + (call[0] as string).includes('UPDATE'), ); expect(updateCalls).toHaveLength(2); }); }); - describe("retryWebhookDelivery edge cases", () => { - it("handles network timeout errors gracefully", async () => { + describe('retryWebhookDelivery edge cases', () => { + it('handles network timeout errors gracefully', async () => { const fetchMock = jest.fn(async () => { - throw new Error("fetch failed"); + throw new Error('fetch failed'); }) as unknown as jest.MockedFunction; global.fetch = fetchMock as unknown as typeof fetch; const now = 1_700_000_000_000; - jest.spyOn(Date, "now").mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); await WebhookService.retryWebhookDelivery( 1, 1, - "https://hook.example.com/callback", + 'https://hook.example.com/callback', undefined, - "evt-001", - "LoanApproved", - { eventId: "evt-001" }, + 'evt-001', + 'LoanApproved', + { eventId: 'evt-001' }, 0, ); expect(fetchMock).toHaveBeenCalledTimes(1); const updateCall = mockQuery.mock.calls[0] as [string, unknown[]]; - expect(updateCall[0]).toContain("UPDATE webhook_deliveries"); + expect(updateCall[0]).toContain('UPDATE webhook_deliveries'); expect(updateCall[1]?.[0]).toBe(1); // attempt_count - expect(updateCall[1]?.[1]).toBe("fetch failed"); // last_error + expect(updateCall[1]?.[1]).toBe('fetch failed'); // last_error expect(updateCall[1]?.[2]).toEqual(new Date(now + getRetryDelayMs(1))); // next_retry_at }); }); diff --git a/backend/src/services/__tests__/yieldHistoryService.test.ts b/backend/src/services/__tests__/yieldHistoryService.test.ts index 7bff8304..2d0aec15 100644 --- a/backend/src/services/__tests__/yieldHistoryService.test.ts +++ b/backend/src/services/__tests__/yieldHistoryService.test.ts @@ -1,46 +1,42 @@ -import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -const mockQuery = - jest.fn<(sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>>(); +const mockQuery = jest.fn<(sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>>(); -jest.unstable_mockModule("../../db/connection.js", () => ({ +jest.unstable_mockModule('../../db/connection.js', () => ({ query: mockQuery, })); -const { buildDepositorYieldHistory, computeApy, normalizeYieldHistoryDays } = - await import("../yieldHistoryService.js"); +const { buildDepositorYieldHistory, computeApy, normalizeYieldHistoryDays } = await import( + '../yieldHistoryService.js' +); -describe("yieldHistoryService", () => { +describe('yieldHistoryService', () => { beforeEach(() => { jest.clearAllMocks(); - process.env.LENDING_POOL_CONTRACT_ID = "CPoolContract"; + process.env.LENDING_POOL_CONTRACT_ID = 'CPoolContract'; }); - it("normalizes days to allowed ranges", () => { + it('normalizes days to allowed ranges', () => { expect(normalizeYieldHistoryDays(7)).toBe(7); expect(normalizeYieldHistoryDays(90)).toBe(90); expect(normalizeYieldHistoryDays(undefined)).toBe(30); expect(normalizeYieldHistoryDays(14)).toBe(30); }); - it("computes annualized APY from period return", () => { + it('computes annualized APY from period return', () => { const apy = computeApy(10, 100, 30); expect(apy).toBeCloseTo(121.67, 1); }); - it("returns empty history when depositor has no events", async () => { + it('returns empty history when depositor has no events', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); mockQuery.mockResolvedValueOnce({ rows: [] }); - const history = await buildDepositorYieldHistory( - "GDepositor", - "GToken", - 30, - ); + const history = await buildDepositorYieldHistory('GDepositor', 'GToken', 30); expect(history).toEqual([]); }); - it("aggregates deposit and yield into increasing net yield", async () => { + it('aggregates deposit and yield into increasing net yield', async () => { const now = new Date(); const yesterday = new Date(now); yesterday.setUTCDate(yesterday.getUTCDate() - 1); @@ -48,14 +44,14 @@ describe("yieldHistoryService", () => { mockQuery.mockResolvedValueOnce({ rows: [ { - event_type: "Deposit", - amount: "1000", + event_type: 'Deposit', + amount: '1000', ledger_closed_at: yesterday, value: null, }, { - event_type: "YieldDistributed", - amount: "100", + event_type: 'YieldDistributed', + amount: '100', ledger_closed_at: now, value: null, }, @@ -65,20 +61,15 @@ describe("yieldHistoryService", () => { mockQuery.mockResolvedValueOnce({ rows: [ { - event_type: "Deposit", - amount: "1000", + event_type: 'Deposit', + amount: '1000', ledger_closed_at: yesterday, value: null, }, ], }); - const history = await buildDepositorYieldHistory( - "GDepositor", - "GToken", - 7, - 1_100_000, - ); + const history = await buildDepositorYieldHistory('GDepositor', 'GToken', 7, 1_100_000); expect(history.length).toBeGreaterThan(0); const latest = history[history.length - 1]!; diff --git a/backend/src/services/auditLogService.ts b/backend/src/services/auditLogService.ts index 99fd50c6..b143a2dc 100644 --- a/backend/src/services/auditLogService.ts +++ b/backend/src/services/auditLogService.ts @@ -1,4 +1,4 @@ -import { query } from "../db/connection.js"; +import { query } from '../db/connection.js'; export interface AuditLogFilters { actor?: string; @@ -41,8 +41,7 @@ export async function getAuditLogs(filters: AuditLogFilters) { values.push(cursor); } - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; values.push(limit + 1); const result = await query( @@ -56,17 +55,13 @@ export async function getAuditLogs(filters: AuditLogFilters) { let total: number | undefined; if (withTotal === true) { - const countResult = await query("SELECT COUNT(*) as count FROM audit_logs"); - total = Number( - (countResult.rows[0] as Record)?.count ?? 0, - ); + const countResult = await query('SELECT COUNT(*) as count FROM audit_logs'); + total = Number((countResult.rows[0] as Record)?.count ?? 0); } return { data, - nextCursor: hasNext - ? String((data[data.length - 1] as Record).id) - : null, + nextCursor: hasNext ? String((data[data.length - 1] as Record).id) : null, total, }; } diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts index 942c92f6..8071e025 100644 --- a/backend/src/services/authService.ts +++ b/backend/src/services/authService.ts @@ -24,23 +24,23 @@ export interface ChallengeMessage { expiresIn: number; } -const JWT_EXPIRES_IN = "24h"; +const JWT_EXPIRES_IN = '24h'; const CHALLENGE_EXPIRES_IN_MS = 5 * 60 * 1000; function getJwtSecret(): string { const secret = process.env.JWT_SECRET; if (!secret) { - throw new Error("JWT_SECRET environment variable is not set"); + throw new Error('JWT_SECRET environment variable is not set'); } return secret; } export function generateChallenge(publicKey: string): ChallengeMessage { if (!StrKey.isValidEd25519PublicKey(publicKey)) { - throw new Error("Invalid Stellar public key"); + throw new Error('Invalid Stellar public key'); } - const nonce = crypto.randomBytes(32).toString("hex"); + const nonce = crypto.randomBytes(32).toString('hex'); const timestamp = Date.now(); const message = `Sign this message to authenticate with RemitLend.\n\nNonce: ${nonce}\nTimestamp: ${timestamp}\n\nThis request will expire in 5 minutes.`; @@ -53,27 +53,20 @@ export function generateChallenge(publicKey: string): ChallengeMessage { }; } -export function verifySignature( - publicKey: string, - message: string, - signature: string, -): boolean { +export function verifySignature(publicKey: string, message: string, signature: string): boolean { if (!StrKey.isValidEd25519PublicKey(publicKey)) { return false; } try { - const signatureBytes = Buffer.from(signature, "base64"); + const signatureBytes = Buffer.from(signature, 'base64'); if (signatureBytes.length !== 64) { return false; } - const messageBytes = Buffer.from(message, "utf-8"); + const messageBytes = Buffer.from(message, 'utf-8'); - return Keypair.fromPublicKey(publicKey).verify( - messageBytes, - signatureBytes, - ); + return Keypair.fromPublicKey(publicKey).verify(messageBytes, signatureBytes); } catch { return false; } @@ -92,7 +85,7 @@ export function generateJwtToken(publicKey: string): string { const role = resolveRoleForWallet(publicKey); const scopes = resolveScopesForRole(role); - const payload: Omit = { + const payload: Omit = { publicKey, role, scopes, @@ -101,7 +94,7 @@ export function generateJwtToken(publicKey: string): string { return jwt.sign(payload, secret, { expiresIn: JWT_EXPIRES_IN, - algorithm: "HS256", + algorithm: 'HS256', }); } @@ -109,7 +102,7 @@ export function verifyJwtToken(token: string): JwtPayload | null { try { const secret = getJwtSecret(); const decoded = jwt.verify(token, secret, { - algorithms: ["HS256"], + algorithms: ['HS256'], }) as JwtPayload; return decoded; @@ -160,15 +153,13 @@ export function decodeJwtToken(token: string): JwtPayload | null { } } -export function extractBearerToken( - authHeader: string | undefined, -): string | null { +export function extractBearerToken(authHeader: string | undefined): string | null { if (!authHeader) { return null; } - const parts = authHeader.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") { + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { return null; } diff --git a/backend/src/services/cacheService.ts b/backend/src/services/cacheService.ts index 43432559..a5275450 100644 --- a/backend/src/services/cacheService.ts +++ b/backend/src/services/cacheService.ts @@ -1,7 +1,7 @@ -import { createClient, type RedisClientType } from "redis"; -import logger from "../utils/logger.js"; +import { createClient, type RedisClientType } from 'redis'; +import logger from '../utils/logger.js'; -const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; +const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; class CacheService { private client: RedisClientType | undefined; @@ -12,23 +12,23 @@ class CacheService { url: REDIS_URL, }); - this.client.on("error", (err) => { + this.client.on('error', (err) => { // In tests, we don't want to spam the console with ECONNREFUSED if Redis isn't running - if (process.env.NODE_ENV !== "test" || err.code !== "ECONNREFUSED") { - logger.withContext().error("Redis Client Error", err); + if (process.env.NODE_ENV !== 'test' || err.code !== 'ECONNREFUSED') { + logger.withContext().error('Redis Client Error', err); } this.isConnected = false; }); - this.client.on("connect", () => { - logger.withContext().info("Redis Client Connected"); + this.client.on('connect', () => { + logger.withContext().info('Redis Client Connected'); this.isConnected = true; }); - this.client.on("reconnecting", () => { + this.client.on('reconnecting', () => { // Only log reconnecting in non-test environments to keep test output clean - if (process.env.NODE_ENV !== "test") { - logger.withContext().info("Redis Client Reconnecting"); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().info('Redis Client Reconnecting'); } }); @@ -42,8 +42,8 @@ class CacheService { this.isConnected = true; } catch (err) { // Silently fail in tests if connection fails, but log in production - if (process.env.NODE_ENV !== "test") { - logger.withContext().error("Failed to connect to Redis", err); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().error('Failed to connect to Redis', err); } throw err; } @@ -56,20 +56,14 @@ class CacheService { * @param value The value to cache (will be stringified) * @param ttlSeconds The TTL in seconds (default: 300 = 5 minutes) */ - async set( - key: string, - value: unknown, - ttlSeconds: number = 300, - ): Promise { + async set(key: string, value: unknown, ttlSeconds: number = 300): Promise { try { await this.ensureConnected(); const stringValue = JSON.stringify(value); await this.client!.setEx(key, ttlSeconds, stringValue); } catch (error) { - if (process.env.NODE_ENV !== "test") { - logger - .withContext() - .error(`Error setting cache for key ${key}`, { error }); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().error(`Error setting cache for key ${key}`, { error }); } } } @@ -87,10 +81,8 @@ class CacheService { return JSON.parse(value) as T; } catch (error) { - if (process.env.NODE_ENV !== "test") { - logger - .withContext() - .error(`Error getting cache for key ${key}`, { error }); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().error(`Error getting cache for key ${key}`, { error }); } return null; } @@ -104,11 +96,7 @@ class CacheService { * @param ttlSeconds The TTL in seconds * @returns true if the key was set, false if the key already existed */ - async setNotExists( - key: string, - value: unknown, - ttlSeconds: number, - ): Promise { + async setNotExists(key: string, value: unknown, ttlSeconds: number): Promise { try { await this.ensureConnected(); if (!this.isConnected) return false; @@ -119,11 +107,9 @@ class CacheService { NX: true, EX: ttlSeconds, }); - return result === "OK"; + return result === 'OK'; } catch (error) { - logger - .withContext() - .error(`Error setting NX cache for key ${key}`, { error }); + logger.withContext().error(`Error setting NX cache for key ${key}`, { error }); return false; } } @@ -137,10 +123,8 @@ class CacheService { await this.ensureConnected(); await this.client!.del(key); } catch (error) { - if (process.env.NODE_ENV !== "test") { - logger - .withContext() - .error(`Error deleting cache for key ${key}`, { error }); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().error(`Error deleting cache for key ${key}`, { error }); } } } @@ -157,10 +141,8 @@ class CacheService { await this.client!.del(keys); } } catch (error) { - if (process.env.NODE_ENV !== "test") { - logger - .withContext() - .error(`Error invalidating pattern ${pattern}`, { error }); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().error(`Error invalidating pattern ${pattern}`, { error }); } } } @@ -169,13 +151,13 @@ class CacheService { * Ping the Redis server to verify connectivity. * Returns "ok" on success or "error" if unreachable. */ - async ping(): Promise<"ok" | "error"> { + async ping(): Promise<'ok' | 'error'> { try { await this.ensureConnected(); const reply = await this.client!.ping(); - return reply === "PONG" ? "ok" : "error"; + return reply === 'PONG' ? 'ok' : 'error'; } catch { - return "error"; + return 'error'; } } diff --git a/backend/src/services/databaseService.ts b/backend/src/services/databaseService.ts index d0f6dbaa..2bba7f97 100644 --- a/backend/src/services/databaseService.ts +++ b/backend/src/services/databaseService.ts @@ -1,5 +1,5 @@ -import { query, getClient } from "../db/connection.js"; -import type { PoolClient } from "pg"; +import { query, getClient } from '../db/connection.js'; +import type { PoolClient } from 'pg'; export interface UserProfile { id: number; @@ -36,17 +36,12 @@ export class UserProfileService { } static async findByPublicKey(publicKey: string): Promise { - const result = await query( - `SELECT * FROM user_profiles WHERE public_key = $1`, - [publicKey], - ); + const result = await query(`SELECT * FROM user_profiles WHERE public_key = $1`, [publicKey]); return (result.rows[0] as UserProfile) || null; } static async findById(id: number): Promise { - const result = await query(`SELECT * FROM user_profiles WHERE id = $1`, [ - id, - ]); + const result = await query(`SELECT * FROM user_profiles WHERE id = $1`, [id]); return (result.rows[0] as UserProfile) || null; } @@ -79,17 +74,14 @@ export class UserProfileService { values.push(publicKey); const result = await query( - `UPDATE user_profiles SET ${updates.join(", ")} WHERE public_key = $${paramIndex} RETURNING *`, + `UPDATE user_profiles SET ${updates.join(', ')} WHERE public_key = $${paramIndex} RETURNING *`, values, ); return (result.rows[0] as UserProfile) || null; } static async delete(publicKey: string): Promise { - const result = await query( - `DELETE FROM user_profiles WHERE public_key = $1`, - [publicKey], - ); + const result = await query(`DELETE FROM user_profiles WHERE public_key = $1`, [publicKey]); return (result.rowCount ?? 0) > 0; } @@ -178,18 +170,11 @@ export class LoanHistoryService { } static async findByLoanId(loanId: number): Promise { - const result = await query( - `SELECT * FROM loan_history WHERE loan_id = $1`, - [loanId], - ); + const result = await query(`SELECT * FROM loan_history WHERE loan_id = $1`, [loanId]); return (result.rows[0] as LoanHistory) || null; } - static async findByBorrower( - publicKey: string, - limit = 50, - offset = 0, - ): Promise { + static async findByBorrower(publicKey: string, limit = 50, offset = 0): Promise { const result = await query( `SELECT * FROM loan_history WHERE borrower_public_key = $1 @@ -200,11 +185,7 @@ export class LoanHistoryService { return result.rows as LoanHistory[]; } - static async findByLender( - publicKey: string, - limit = 50, - offset = 0, - ): Promise { + static async findByLender(publicKey: string, limit = 50, offset = 0): Promise { const result = await query( `SELECT * FROM loan_history WHERE lender_public_key = $1 @@ -215,10 +196,7 @@ export class LoanHistoryService { return result.rows as LoanHistory[]; } - static async update( - loanId: number, - input: UpdateLoanHistoryInput, - ): Promise { + static async update(loanId: number, input: UpdateLoanHistoryInput): Promise { const updates: string[] = []; const values: unknown[] = []; let paramIndex = 1; @@ -264,17 +242,13 @@ export class LoanHistoryService { values.push(loanId); const result = await query( - `UPDATE loan_history SET ${updates.join(", ")} WHERE loan_id = $${paramIndex} RETURNING *`, + `UPDATE loan_history SET ${updates.join(', ')} WHERE loan_id = $${paramIndex} RETURNING *`, values, ); return (result.rows[0] as LoanHistory) || null; } - static async findByStatus( - status: string, - limit = 50, - offset = 0, - ): Promise { + static async findByStatus(status: string, limit = 50, offset = 0): Promise { const result = await query( `SELECT * FROM loan_history WHERE status = $1 @@ -335,17 +309,12 @@ export class IndexedEventsService { } static async findById(id: number): Promise { - const result = await query(`SELECT * FROM indexed_events WHERE id = $1`, [ - id, - ]); + const result = await query(`SELECT * FROM indexed_events WHERE id = $1`, [id]); return (result.rows[0] as IndexedEvent) || null; } static async findByEventId(eventId: string): Promise { - const result = await query( - `SELECT * FROM indexed_events WHERE event_id = $1`, - [eventId], - ); + const result = await query(`SELECT * FROM indexed_events WHERE event_id = $1`, [eventId]); return (result.rows[0] as IndexedEvent) || null; } @@ -378,11 +347,7 @@ export class IndexedEventsService { return result.rows as IndexedEvent[]; } - static async findByContract( - contractId: string, - limit = 50, - offset = 0, - ): Promise { + static async findByContract(contractId: string, limit = 50, offset = 0): Promise { const result = await query( `SELECT * FROM indexed_events WHERE contract_id = $1 @@ -393,30 +358,25 @@ export class IndexedEventsService { return result.rows as IndexedEvent[]; } - static async deleteByLedgerRange( - startLedger: number, - endLedger: number, - ): Promise { - const result = await query( - `DELETE FROM indexed_events WHERE ledger >= $1 AND ledger <= $2`, - [startLedger, endLedger], - ); + static async deleteByLedgerRange(startLedger: number, endLedger: number): Promise { + const result = await query(`DELETE FROM indexed_events WHERE ledger >= $1 AND ledger <= $2`, [ + startLedger, + endLedger, + ]); return result.rowCount ?? 0; } } export class DatabaseService { - static async withTransaction( - callback: (client: PoolClient) => Promise, - ): Promise { + static async withTransaction(callback: (client: PoolClient) => Promise): Promise { const client = await getClient(); try { - await client.query("BEGIN"); + await client.query('BEGIN'); const result = await callback(client); - await client.query("COMMIT"); + await client.query('COMMIT'); return result; } catch (error) { - await client.query("ROLLBACK"); + await client.query('ROLLBACK'); throw error; } finally { client.release(); @@ -425,7 +385,7 @@ export class DatabaseService { static async healthCheck(): Promise { try { - const result = await query("SELECT 1"); + const result = await query('SELECT 1'); return result.rowCount === 1; } catch { return false; diff --git a/backend/src/services/defaultChecker.ts b/backend/src/services/defaultChecker.ts index cf3a5ca6..e6a14c5f 100644 --- a/backend/src/services/defaultChecker.ts +++ b/backend/src/services/defaultChecker.ts @@ -5,24 +5,21 @@ import { TransactionBuilder, nativeToScVal, rpc, -} from "@stellar/stellar-sdk"; -import { query } from "../db/connection.js"; -import logger from "../utils/logger.js"; -import { AppError } from "../errors/AppError.js"; -import { - createSorobanRpcServer, - getStellarNetworkPassphrase, -} from "../config/stellar.js"; +} from '@stellar/stellar-sdk'; +import { query } from '../db/connection.js'; +import logger from '../utils/logger.js'; +import { AppError } from '../errors/AppError.js'; +import { createSorobanRpcServer, getStellarNetworkPassphrase } from '../config/stellar.js'; -import { cacheService } from "./cacheService.js"; -import { jobMetricsService } from "./jobMetricsService.js"; +import { cacheService } from './cacheService.js'; +import { jobMetricsService } from './jobMetricsService.js'; /** * Mirrors `LoanManager::DEFAULT_TERM_LEDGERS` in `contracts/loan_manager/src/lib.rs`. * Used to estimate on-chain due ledgers from indexed `LoanApproved` events. */ const DEFAULT_TERM_LEDGERS = 17_280; -const LOCK_KEY = "default_checker:running"; +const LOCK_KEY = 'default_checker:running'; const LOCK_TTL_SECONDS = 600; // 10 minutes - prevents stuck locks from crashed runs export interface DefaultCheckBatchResult { @@ -48,7 +45,7 @@ export interface DefaultCheckRunResult { } function parsePositiveInt(value: string | undefined, fallback: number): number { - const n = parseInt(value ?? "", 10); + const n = parseInt(value ?? '', 10); return Number.isFinite(n) && n > 0 ? n : fallback; } @@ -96,32 +93,17 @@ export class DefaultChecker { private concurrency: number; constructor() { - this.contractId = process.env.LOAN_MANAGER_CONTRACT_ID || ""; - this.termLedgers = parsePositiveInt( - process.env.LOAN_TERM_LEDGERS, - DEFAULT_TERM_LEDGERS, - ); + this.contractId = process.env.LOAN_MANAGER_CONTRACT_ID || ''; + this.termLedgers = parsePositiveInt(process.env.LOAN_TERM_LEDGERS, DEFAULT_TERM_LEDGERS); this.batchSize = parsePositiveInt(process.env.DEFAULT_CHECK_BATCH_SIZE, 25); this.batchTimeoutMs = parsePositiveInt( process.env.DEFAULT_CHECK_BATCH_TIMEOUT_MS, 5 * 60 * 1000, ); - this.maxLoansPerRun = parsePositiveInt( - process.env.DEFAULT_CHECK_MAX_LOANS_PER_RUN, - 500, - ); - this.pollAttempts = parsePositiveInt( - process.env.DEFAULT_CHECK_POLL_ATTEMPTS, - 30, - ); - this.pollSleepMs = parsePositiveInt( - process.env.DEFAULT_CHECK_POLL_SLEEP_MS, - 1_000, - ); - this.concurrency = parsePositiveInt( - process.env.DEFAULT_CHECK_CONCURRENCY, - 3, - ); + this.maxLoansPerRun = parsePositiveInt(process.env.DEFAULT_CHECK_MAX_LOANS_PER_RUN, 500); + this.pollAttempts = parsePositiveInt(process.env.DEFAULT_CHECK_POLL_ATTEMPTS, 30); + this.pollSleepMs = parsePositiveInt(process.env.DEFAULT_CHECK_POLL_SLEEP_MS, 1_000); + this.concurrency = parsePositiveInt(process.env.DEFAULT_CHECK_CONCURRENCY, 3); } private assertConfigured(): { @@ -131,14 +113,14 @@ export class DefaultChecker { } { if (!this.contractId) { throw AppError.internal( - "Default checker misconfiguration: LOAN_MANAGER_CONTRACT_ID is not set", + 'Default checker misconfiguration: LOAN_MANAGER_CONTRACT_ID is not set', ); } const secret = process.env.LOAN_MANAGER_ADMIN_SECRET; if (!secret) { throw AppError.internal( - "Default checker misconfiguration: LOAN_MANAGER_ADMIN_SECRET is not set", + 'Default checker misconfiguration: LOAN_MANAGER_ADMIN_SECRET is not set', ); } @@ -147,7 +129,7 @@ export class DefaultChecker { signer = Keypair.fromSecret(secret); } catch { throw AppError.internal( - "Default checker misconfiguration: LOAN_MANAGER_ADMIN_SECRET is invalid", + 'Default checker misconfiguration: LOAN_MANAGER_ADMIN_SECRET is invalid', ); } @@ -248,12 +230,9 @@ export class DefaultChecker { } | undefined; - const overdueCount = - row?.overdue_count != null ? Number(row.overdue_count) : 0; + const overdueCount = row?.overdue_count != null ? Number(row.overdue_count) : 0; const oldestDueLedger = - row?.oldest_due_ledger != null - ? Number(row.oldest_due_ledger) - : undefined; + row?.oldest_due_ledger != null ? Number(row.oldest_due_ledger) : undefined; const ledgersPastOldestDue = oldestDueLedger != null && Number.isFinite(oldestDueLedger) @@ -279,7 +258,7 @@ export class DefaultChecker { ): Promise { const account = await server.getAccount(signer.publicKey()); - const loanIdsScVal = nativeToScVal(loanIds, { type: "u32" }); + const loanIdsScVal = nativeToScVal(loanIds, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -288,7 +267,7 @@ export class DefaultChecker { .addOperation( Operation.invokeContractFunction({ contract: this.contractId, - function: "check_defaults", + function: 'check_defaults', args: [loanIdsScVal], }), ) @@ -320,7 +299,7 @@ export class DefaultChecker { return { loanIds, ...(submitStatus !== undefined ? { submitStatus } : {}), - error: "sendTransaction returned no hash", + error: 'sendTransaction returned no hash', }; } @@ -333,7 +312,7 @@ export class DefaultChecker { txStatus = polled.status; } catch (error) { const message = error instanceof Error ? error.message : String(error); - logger.withContext().warn("Default check transaction polling failed", { + logger.withContext().warn('Default check transaction polling failed', { txHash, message, }); @@ -370,17 +349,18 @@ export class DefaultChecker { timeoutHandle.unref?.(); }); - const submissionPromise: Promise = - this.submitCheckDefaults(server, signer, passphrase, loanIds).catch( - (error) => { - const message = - error instanceof Error ? error.message : String(error); - return { - loanIds, - error: `default check batch failed: ${message}`, - } satisfies DefaultCheckBatchResult; - }, - ); + const submissionPromise: Promise = this.submitCheckDefaults( + server, + signer, + passphrase, + loanIds, + ).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + return { + loanIds, + error: `default check batch failed: ${message}`, + } satisfies DefaultCheckBatchResult; + }); const result = await Promise.race([submissionPromise, timeoutPromise]); @@ -389,7 +369,7 @@ export class DefaultChecker { } if (result.timedOut) { - logger.withContext().warn("Default check batch timed out", { + logger.withContext().warn('Default check batch timed out', { loanIds, timeoutMs: this.batchTimeoutMs, }); @@ -405,16 +385,10 @@ export class DefaultChecker { private async acquireLock(): Promise { try { const lockValue = `${Date.now()}-${Math.random().toString(16).slice(2)}`; - const acquired = await cacheService.setNotExists( - LOCK_KEY, - lockValue, - LOCK_TTL_SECONDS, - ); + const acquired = await cacheService.setNotExists(LOCK_KEY, lockValue, LOCK_TTL_SECONDS); return acquired; } catch (error) { - logger - .withContext() - .error("Failed to acquire default checker lock", { error }); + logger.withContext().error('Failed to acquire default checker lock', { error }); return false; } } @@ -426,9 +400,7 @@ export class DefaultChecker { try { await cacheService.delete(LOCK_KEY); } catch (error) { - logger - .withContext() - .error("Failed to release default checker lock", { error }); + logger.withContext().error('Failed to release default checker lock', { error }); } } @@ -437,20 +409,16 @@ export class DefaultChecker { * - explicit `loanIds` (validated + de-duped), or * - all overdue loans discovered from `loan_events` (bounded by env limits). */ - async checkOverdueLoans( - loanIds?: number[], - ): Promise { + async checkOverdueLoans(loanIds?: number[]): Promise { const startTime = Date.now(); - const jobName = "defaultChecker"; + const jobName = 'defaultChecker'; // Try to acquire distributed lock to prevent overlapping runs const lockAcquired = await this.acquireLock(); if (!lockAcquired) { logger .withContext() - .warn( - "Default checker run skipped - another instance is already running", - ); + .warn('Default checker run skipped - another instance is already running'); return null; } @@ -464,9 +432,7 @@ export class DefaultChecker { const stats = await this.fetchOverdueStats(currentLedger); const explicitIds = loanIds - ? Array.from( - new Set(loanIds.filter((id) => Number.isInteger(id) && id > 0)), - ) + ? Array.from(new Set(loanIds.filter((id) => Number.isInteger(id) && id > 0))) : undefined; const targetIds = @@ -474,7 +440,7 @@ export class DefaultChecker { ? explicitIds : await this.fetchOverdueLoanIds(currentLedger); - logger.withContext().info("default_check.run.start", { + logger.withContext().info('default_check.run.start', { runId, currentLedger, termLedgers: this.termLedgers, @@ -488,43 +454,28 @@ export class DefaultChecker { targetLoanCount: targetIds.length, }); - const allChunks = chunk(targetIds, this.batchSize).filter( - (b) => b.length > 0, - ); - const batchResults = await mapConcurrent( - allChunks, - this.concurrency, - async (batch) => { - const result = await this.submitCheckDefaultsWithTimeout( - server, - signer, - passphrase, - batch, - ); - - logger.withContext().info("default_check.batch", { - runId, - loanIds: result.loanIds, - txHash: result.txHash, - submitStatus: result.submitStatus, - txStatus: result.txStatus, - error: result.error, - timedOut: result.timedOut, - }); - - return result; - }, - ); + const allChunks = chunk(targetIds, this.batchSize).filter((b) => b.length > 0); + const batchResults = await mapConcurrent(allChunks, this.concurrency, async (batch) => { + const result = await this.submitCheckDefaultsWithTimeout(server, signer, passphrase, batch); + + logger.withContext().info('default_check.batch', { + runId, + loanIds: result.loanIds, + txHash: result.txHash, + submitStatus: result.submitStatus, + txStatus: result.txStatus, + error: result.error, + timedOut: result.timedOut, + }); + + return result; + }); const loansChecked = targetIds.length; - const successfulSubmissions = batchResults.filter( - (b) => !b.error && b.txHash, - ).length; - const failedSubmissions = batchResults.filter( - (b) => b.error || !b.txHash, - ).length; - - logger.withContext().info("default_check.run.complete", { + const successfulSubmissions = batchResults.filter((b) => !b.error && b.txHash).length; + const failedSubmissions = batchResults.filter((b) => b.error || !b.txHash).length; + + logger.withContext().info('default_check.run.complete', { runId, batches: batchResults.length, loansChecked, @@ -548,9 +499,7 @@ export class DefaultChecker { loansChecked, successfulSubmissions, failedSubmissions, - ...(stats.oldestDueLedger !== undefined - ? { oldestDueLedger: stats.oldestDueLedger } - : {}), + ...(stats.oldestDueLedger !== undefined ? { oldestDueLedger: stats.oldestDueLedger } : {}), ...(stats.ledgersPastOldestDue !== undefined ? { ledgersPastOldestDue: stats.ledgersPastOldestDue } : {}), @@ -559,11 +508,7 @@ export class DefaultChecker { } catch (error) { // Record failure metrics const durationMs = Date.now() - startTime; - jobMetricsService.recordFailure( - jobName, - error as Error | string, - durationMs, - ); + jobMetricsService.recordFailure(jobName, error as Error | string, durationMs); throw error; } finally { // Always release the lock, even if the run failed @@ -580,26 +525,20 @@ let inFlight = false; export function startDefaultCheckerScheduler(): void { if (interval) return; - if (process.env.NODE_ENV === "test") { + if (process.env.NODE_ENV === 'test') { return; } - if ( - !process.env.LOAN_MANAGER_CONTRACT_ID || - !process.env.LOAN_MANAGER_ADMIN_SECRET - ) { + if (!process.env.LOAN_MANAGER_CONTRACT_ID || !process.env.LOAN_MANAGER_ADMIN_SECRET) { logger .withContext() .warn( - "Default checker scheduler disabled (set LOAN_MANAGER_CONTRACT_ID and LOAN_MANAGER_ADMIN_SECRET)", + 'Default checker scheduler disabled (set LOAN_MANAGER_CONTRACT_ID and LOAN_MANAGER_ADMIN_SECRET)', ); return; } - const intervalMs = parsePositiveInt( - process.env.DEFAULT_CHECK_INTERVAL_MS, - 30 * 60 * 1000, - ); + const intervalMs = parsePositiveInt(process.env.DEFAULT_CHECK_INTERVAL_MS, 30 * 60 * 1000); interval = setInterval(() => { void (async () => { @@ -608,22 +547,18 @@ export function startDefaultCheckerScheduler(): void { try { await defaultChecker.checkOverdueLoans(); } catch (error) { - logger - .withContext() - .error("Default checker scheduled run failed", { error }); + logger.withContext().error('Default checker scheduled run failed', { error }); } finally { inFlight = false; } })(); }, intervalMs); - logger - .withContext() - .info("Default checker scheduler started", { intervalMs }); + logger.withContext().info('Default checker scheduler started', { intervalMs }); } export function stopDefaultCheckerScheduler(): void { if (interval) clearInterval(interval); interval = undefined; - logger.withContext().info("Default checker scheduler stopped"); + logger.withContext().info('Default checker scheduler stopped'); } diff --git a/backend/src/services/eventIndexer.ts b/backend/src/services/eventIndexer.ts index d5f5c484..439b31f4 100644 --- a/backend/src/services/eventIndexer.ts +++ b/backend/src/services/eventIndexer.ts @@ -1,57 +1,51 @@ -import { rpc as SorobanRpc, scValToNative, xdr } from "@stellar/stellar-sdk"; -import { type PoolClient, query, withTransaction } from "../db/connection.js"; -import logger from "../utils/logger.js"; -import { - createRequestId, - runWithRequestContext, -} from "../utils/requestContext.js"; +import { rpc as SorobanRpc, scValToNative, xdr } from '@stellar/stellar-sdk'; +import { type PoolClient, query, withTransaction } from '../db/connection.js'; +import logger from '../utils/logger.js'; +import { createRequestId, runWithRequestContext } from '../utils/requestContext.js'; import { type IndexedLoanEvent, SUPPORTED_WEBHOOK_EVENT_TYPES, type WebhookEventType, webhookService, -} from "./webhookService.js"; -import { eventStreamService } from "./eventStreamService.js"; -import { - notificationService, - type NotificationType, -} from "./notificationService.js"; -import { sorobanService } from "./sorobanService.js"; -import { updateUserScoresBulk } from "./scoresService.js"; -import { AppError } from "../errors/AppError.js"; -import { recordIndexerLedgers } from "../middleware/metrics.js"; +} from './webhookService.js'; +import { eventStreamService } from './eventStreamService.js'; +import { notificationService, type NotificationType } from './notificationService.js'; +import { sorobanService } from './sorobanService.js'; +import { updateUserScoresBulk } from './scoresService.js'; +import { AppError } from '../errors/AppError.js'; +import { recordIndexerLedgers } from '../middleware/metrics.js'; const EVENT_TYPE_ALIASES: Record = { - Mint: "NFTMinted", - AdmRemint: "NFTMinted", - ScoreUpd: "ScoreUpdated", - Seized: "NFTSeized", - NftBurned: "NFTBurned", - MinScore: "MinScoreUpdated", - GovProp: "ProposalCreated", - GovAppr: "ProposalApproved", - GovFin: "ProposalFinalized", - GovCncl: "ProposalCancelled", - GovEmerg: "ProposalCancelled", - GovExp: "ProposalCancelled", - ColDep: "CollateralDeposited", - ColRel: "CollateralReleased", + Mint: 'NFTMinted', + AdmRemint: 'NFTMinted', + ScoreUpd: 'ScoreUpdated', + Seized: 'NFTSeized', + NftBurned: 'NFTBurned', + MinScore: 'MinScoreUpdated', + GovProp: 'ProposalCreated', + GovAppr: 'ProposalApproved', + GovFin: 'ProposalFinalized', + GovCncl: 'ProposalCancelled', + GovEmerg: 'ProposalCancelled', + GovExp: 'ProposalCancelled', + ColDep: 'CollateralDeposited', + ColRel: 'CollateralReleased', }; const ADMIN_CONFIG_EVENT_TYPES: ReadonlySet = new Set([ - "MinScoreUpdated", - "InterestRateUpdated", - "DefaultTermUpdated", - "TermLimitsUpdated", - "LateFeeRateUpdated", - "GracePeriodUpdated", - "DefaultWindowUpdated", - "MaxLoanAmountUpdated", - "MinRepaymentUpdated", - "MaxLoansPerBorrower", - "MinRateBpsUpdated", - "MaxRateBpsUpdated", - "RateOracleUpdated", + 'MinScoreUpdated', + 'InterestRateUpdated', + 'DefaultTermUpdated', + 'TermLimitsUpdated', + 'LateFeeRateUpdated', + 'GracePeriodUpdated', + 'DefaultWindowUpdated', + 'MaxLoanAmountUpdated', + 'MinRepaymentUpdated', + 'MaxLoansPerBorrower', + 'MinRateBpsUpdated', + 'MaxRateBpsUpdated', + 'RateOracleUpdated', ]); export interface SorobanRawEvent { @@ -119,20 +113,14 @@ export class EventIndexer { constructor(config: EventIndexerConfig); constructor(rpcUrl: string, contractId: string); - constructor( - configOrRpcUrl: EventIndexerConfig | string, - contractId?: string, - ) { - const thresholdRaw = Number.parseInt( - process.env.QUARANTINE_ALERT_THRESHOLD ?? "25", - 10, - ); + constructor(configOrRpcUrl: EventIndexerConfig | string, contractId?: string) { + const thresholdRaw = Number.parseInt(process.env.QUARANTINE_ALERT_THRESHOLD ?? '25', 10); this.quarantineAlertThreshold = Number.isFinite(thresholdRaw) && thresholdRaw > 0 ? thresholdRaw : 25; - if (typeof configOrRpcUrl === "string") { + if (typeof configOrRpcUrl === 'string') { if (!contractId) { - throw new Error("contractId is required when using rpcUrl constructor"); + throw new Error('contractId is required when using rpcUrl constructor'); } this.rpc = new SorobanRpc.Server(configOrRpcUrl); this.contractIds = [contractId]; @@ -152,7 +140,7 @@ export class EventIndexer { ...(configOrRpcUrl.contractId ? [configOrRpcUrl.contractId] : []), ].filter(Boolean); if (normalized.length === 0) { - throw new Error("At least one contractId must be configured for indexer"); + throw new Error('At least one contractId must be configured for indexer'); } this.contractIds = [...new Set(normalized)]; this.pollIntervalMs = configOrRpcUrl.pollIntervalMs ?? 30_000; @@ -173,9 +161,7 @@ export class EventIndexer { async start(): Promise { if (this.running) { - logger - .withContext() - .warn("Indexer start requested while already running"); + logger.withContext().warn('Indexer start requested while already running'); return; } @@ -196,9 +182,7 @@ export class EventIndexer { try { await this.activePollPromise; } catch (error) { - logger - .withContext() - .warn("Indexer stop awaited a failing poll iteration", { error }); + logger.withContext().warn('Indexer stop awaited a failing poll iteration', { error }); } finally { this.activePollPromise = null; } @@ -254,7 +238,7 @@ export class EventIndexer { this.activePollPromise = pollPromise; await pollPromise; } catch (error) { - logger.withContext().error("Indexer poll iteration failed", { error }); + logger.withContext().error('Indexer poll iteration failed', { error }); } finally { if (this.activePollPromise === pollPromise) { this.activePollPromise = null; @@ -291,15 +275,12 @@ export class EventIndexer { } ).getLatestLedger()) as Record; - const candidate = - latest.sequence ?? latest.sequenceNumber ?? latest.seq ?? latest.id; + const candidate = latest.sequence ?? latest.sequenceNumber ?? latest.seq ?? latest.id; const sequence = Number(candidate); return Number.isFinite(sequence) && sequence > 0 ? sequence : 0; } catch (error) { - logger - .withContext() - .warn("Failed to fetch latest ledger sequence", { error }); + logger.withContext().warn('Failed to fetch latest ledger sequence', { error }); return 0; } } @@ -348,15 +329,12 @@ export class EventIndexer { } } - private async processChunk( - startLedger: number, - endLedger: number, - ): Promise { + private async processChunk(startLedger: number, endLedger: number): Promise { const correlationId = `indexer-${createRequestId()}`; return runWithRequestContext(correlationId, async () => { if (endLedger < startLedger) { - logger.withContext().warn("Skipping invalid ledger range", { + logger.withContext().warn('Skipping invalid ledger range', { startLedger, endLedger, }); @@ -386,7 +364,7 @@ export class EventIndexer { startLedger, ); - logger.withContext().info("Indexer processed chunk", { + logger.withContext().info('Indexer processed chunk', { startLedger, endLedger, fetchedEvents: events.length, @@ -399,7 +377,7 @@ export class EventIndexer { insertedEvents: storeResult.insertedCount, }; } catch (error) { - logger.withContext().error("Error processing event chunk", { + logger.withContext().error('Error processing event chunk', { startLedger, endLedger, error, @@ -425,7 +403,7 @@ export class EventIndexer { limit: this.batchSize, filters: [ { - type: "contract", + type: 'contract', contractIds: this.contractIds, }, ], @@ -454,9 +432,7 @@ export class EventIndexer { return result.sort((a, b) => Number(a.ledger) - Number(b.ledger)); } - private async storeEvents( - events: SorobanRawEvent[], - ): Promise { + private async storeEvents(events: SorobanRawEvent[]): Promise { const parsedEvents: ContractEvent[] = []; let quarantineAttempts = 0; @@ -467,7 +443,7 @@ export class EventIndexer { parsedEvents.push(parsed); } } catch (error) { - logger.withContext().warn("Failed to parse event", { + logger.withContext().warn('Failed to parse event', { eventId: event.id, error, }); @@ -537,7 +513,7 @@ export class EventIndexer { `INSERT INTO audit_logs (actor, action, target, payload, ip_address) VALUES ($1, $2, $3, $4::jsonb, $5)`, [ - event.address ?? "SYSTEM", + event.address ?? 'SYSTEM', `ADMIN_CONFIG_${event.eventType}`, `contract:${event.contractId}`, JSON.stringify({ @@ -563,14 +539,14 @@ export class EventIndexer { * payload — { eventId, loanId, borrower, txHash } * ip_address — null (on-chain action, no HTTP request IP) */ - if (event.eventType === "LoanApprv") { + if (event.eventType === 'LoanApprv') { await client.query( `INSERT INTO audit_logs (actor, action, target, payload, ip_address) VALUES ($1, $2, $3, $4::jsonb, $5)`, [ - event.adminAddress ?? "SYSTEM", - "loan_approved", - `loan:${event.loanId ?? "unknown"}`, + event.adminAddress ?? 'SYSTEM', + 'loan_approved', + `loan:${event.loanId ?? 'unknown'}`, JSON.stringify({ eventId: event.eventId, loanId: event.loanId ?? null, @@ -584,7 +560,7 @@ export class EventIndexer { // Aggregate score deltas per borrower; a single bulk upsert at // the end of the transaction avoids N+1 score updates. - if (event.eventType === "LoanRepaid") { + if (event.eventType === 'LoanRepaid') { const { repaymentDelta } = sorobanService.getScoreConfig(); if (event.address) { scoreUpdates.set( @@ -593,8 +569,8 @@ export class EventIndexer { ); } } else if ( - event.eventType === "LoanDefaulted" || - event.eventType === "CollateralLiquidated" + event.eventType === 'LoanDefaulted' || + event.eventType === 'CollateralLiquidated' ) { const { defaultPenalty } = sorobanService.getScoreConfig(); if (event.address) { @@ -618,7 +594,7 @@ export class EventIndexer { for (const event of insertedEvents) { webhookService.dispatch(event).catch((error) => { - logger.withContext().error("Webhook dispatch failed", { + logger.withContext().error('Webhook dispatch failed', { eventId: event.eventId, error, }); @@ -636,7 +612,7 @@ export class EventIndexer { }); this.triggerNotification(event).catch((error) => { - logger.withContext().error("Notification trigger failed", { + logger.withContext().error('Notification trigger failed', { eventId: event.eventId, error, }); @@ -659,14 +635,14 @@ export class EventIndexer { let termLedgers: number | undefined; let borrowerRefund: string | undefined; - if (type === "LoanRequested") { + if (type === 'LoanRequested') { // (type, loan_id, borrower), amount if (!event.topic[1] || !event.topic[2]) return null; loanId = this.decodeLoanId(event.topic[1]); if (loanId === undefined) return null; address = this.decodeAddress(event.topic[2]); amount = this.decodeAmount(event.value); - } else if (type === "LoanApproved") { + } else if (type === 'LoanApproved') { // (type, loan_id, borrower), [interest_rate_bps, term_ledgers] if (!event.topic[1] || !event.topic[2]) return null; loanId = this.decodeLoanId(event.topic[1]); @@ -684,70 +660,62 @@ export class EventIndexer { termLedgers = Number(data[1]); if (!Number.isFinite(interestRateBps)) { - throw new Error( - `LoanApproved event has invalid interest_rate_bps: ${event.id}`, - ); + throw new Error(`LoanApproved event has invalid interest_rate_bps: ${event.id}`); } if (!Number.isFinite(termLedgers)) { - throw new Error( - `LoanApproved event has invalid term_ledgers: ${event.id}`, - ); + throw new Error(`LoanApproved event has invalid term_ledgers: ${event.id}`); } - } else if (type === "LoanRepaid") { + } else if (type === 'LoanRepaid') { if (!event.topic[1] || !event.topic[2]) return null; address = this.decodeAddress(event.topic[1]); loanId = this.decodeLoanId(event.topic[2]); amount = this.decodeAmount(event.value); - } else if (type === "LoanDefaulted") { + } else if (type === 'LoanDefaulted') { if (!event.topic[1]) return null; loanId = this.decodeLoanId(event.topic[1]); if (loanId === undefined) return null; address = this.decodeAddress(event.value); - } else if (type === "CollateralLiquidated") { + } else if (type === 'CollateralLiquidated') { if (!event.topic[1]) return null; loanId = this.decodeLoanId(event.topic[1]); if (loanId === undefined) return null; amount = this.decodeAmount(event.value); - } else if ( - type === "Deposit" || - type === "Withdraw" || - type === "EmergencyWithdraw" - ) { + } else if (type === 'Deposit' || type === 'Withdraw' || type === 'EmergencyWithdraw') { if (!event.topic[1]) return null; address = this.decodeAddress(event.topic[1]); // LP events have (amount, shares) in value amount = this.decodeTupleFirstNumericValue(event.value); } else if ( - type === "NFTMinted" || - type === "ScoreUpdated" || - type === "NFTSeized" || - type === "NFTBurned" + type === 'NFTMinted' || + type === 'ScoreUpdated' || + type === 'NFTSeized' || + type === 'NFTBurned' ) { if (!event.topic[1]) return null; address = this.decodeAddress(event.topic[1]); - if (type === "NFTMinted" || type === "ScoreUpdated") { + if (type === 'NFTMinted' || type === 'ScoreUpdated') { amount = this.decodeAmount(event.value); } } else if ( - type === "ProposalCreated" || - type === "ProposalApproved" || - type === "ProposalFinalized" || - type === "ProposalCancelled" + type === 'ProposalCreated' || + type === 'ProposalApproved' || + type === 'ProposalFinalized' || + type === 'ProposalCancelled' ) { if (!event.topic[1]) return null; address = this.decodeAddress(event.topic[1]); - } else if (type === "Transfer") { + } else if (type === 'Transfer') { // (from, to), () if (event.topic[2]) { address = this.decodeAddress(event.topic[2]); } - } else if (type === "LoanRefinanced") { + } else if (type === 'LoanRefinanced') { // (type, loan_id, borrower), [new_amount, new_term] if (!event.topic[1] || !event.topic[2]) return null; loanId = this.decodeLoanId(event.topic[1]); address = this.decodeAddress(event.topic[2]); amount = this.decodeTupleFirstNumericValue(event.value); - } else if (type === "LoanExtended") { + } else if (type === 'LoanExtended') { // (type, loan_id, borrower), [new_due_ledger, fee_amount, extension_count] if (!event.topic[1] || !event.topic[2]) return null; loanId = this.decodeLoanId(event.topic[1]); @@ -756,31 +724,31 @@ export class EventIndexer { if (Array.isArray(data) && data.length >= 2) { amount = data[1].toString(); } - } else if (type === "LoanCancelled") { + } else if (type === 'LoanCancelled') { // (type, borrower), loan_id if (!event.topic[1]) return null; address = this.decodeAddress(event.topic[1]); loanId = this.decodeLoanId(event.value); - } else if (type === "LoanRejected") { + } else if (type === 'LoanRejected') { // (type, loan_id), reason if (!event.topic[1]) return null; loanId = this.decodeLoanId(event.topic[1]); - } else if (type === "LateFeeCharged") { + } else if (type === 'LateFeeCharged') { // (type, loan_id), amount if (!event.topic[1]) return null; loanId = this.decodeLoanId(event.topic[1]); amount = this.decodeAmount(event.value); - } else if (type === "CollateralReturned") { + } else if (type === 'CollateralReturned') { // (type, borrower, loan_id), amount if (!event.topic[1] || !event.topic[2]) return null; address = this.decodeAddress(event.topic[1]); loanId = this.decodeLoanId(event.topic[2]); amount = this.decodeAmount(event.value); - } else if (type === "YieldDistributed" || type === "DepositCapUpdated") { + } else if (type === 'YieldDistributed' || type === 'DepositCapUpdated') { // (type, token), amount / [old, new] if (!event.topic[1]) return null; address = this.decodeAddress(event.topic[1]); - if (type === "YieldDistributed") { + if (type === 'YieldDistributed') { amount = this.decodeAmount(event.value); } else { const data = scValToNative(event.value); @@ -788,84 +756,81 @@ export class EventIndexer { amount = data[1].toString(); } } - } else if (type === "WithdrawalCooldownUpdated") { + } else if (type === 'WithdrawalCooldownUpdated') { // (type), [old, new] const data = scValToNative(event.value); if (Array.isArray(data) && data.length >= 2) { amount = data[1].toString(); } - } else if (type === "MinScoreUpdated") { + } else if (type === 'MinScoreUpdated') { // (type, admin), [old_score, new_score] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "InterestRateUpdated") { + } else if (type === 'InterestRateUpdated') { // (type), [old_rate, new_rate] amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "DefaultTermUpdated") { + } else if (type === 'DefaultTermUpdated') { // (type), [old_term, new_term] amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "TermLimitsUpdated") { + } else if (type === 'TermLimitsUpdated') { // (type), [min_term, max_term] amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "LateFeeRateUpdated") { + } else if (type === 'LateFeeRateUpdated') { // (type, admin), [old_rate, new_rate] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "GracePeriodUpdated") { + } else if (type === 'GracePeriodUpdated') { // (type, admin), [old_ledgers, new_ledgers] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "DefaultWindowUpdated") { + } else if (type === 'DefaultWindowUpdated') { // (type, admin), [old_ledgers, new_ledgers] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "MaxLoanAmountUpdated") { + } else if (type === 'MaxLoanAmountUpdated') { // (type, admin), [old_amount, new_amount] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "MinRepaymentUpdated") { + } else if (type === 'MinRepaymentUpdated') { // (type, admin), [old_amount, new_amount] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "MaxLoansPerBorrower") { + } else if (type === 'MaxLoansPerBorrower') { // (type, admin), [old_max, new_max] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "MinRateBpsUpdated") { + } else if (type === 'MinRateBpsUpdated') { // (type, admin), [old_rate, new_rate] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "MaxRateBpsUpdated") { + } else if (type === 'MaxRateBpsUpdated') { // (type, admin), [old_rate, new_rate] if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); } amount = this.decodeTupleSecondNumericValue(event.value); - } else if (type === "RateOracleUpdated") { + } else if (type === 'RateOracleUpdated') { // (type), [old_oracle, new_oracle] address = this.decodeTupleSecondAddress(event.value); - } else if (type === "PoolPaused" || type === "PoolUnpaused") { + } else if (type === 'PoolPaused' || type === 'PoolUnpaused') { // (type) - } else if ( - type === "CollateralDeposited" || - type === "CollateralReleased" - ) { + } else if (type === 'CollateralDeposited' || type === 'CollateralReleased') { // (type, borrower, loan_id), amount/() if (event.topic[1]) { address = this.decodeAddress(event.topic[1]); @@ -873,10 +838,10 @@ export class EventIndexer { if (event.topic[2]) { loanId = this.decodeLoanId(event.topic[2]); } - if (type === "CollateralDeposited") { + if (type === 'CollateralDeposited') { amount = this.decodeAmount(event.value); } - } else if (type === "ScoreDecr") { + } else if (type === 'ScoreDecr') { // (old_score, new_score, symbol) if (!event.topic[1]) return null; address = this.decodeAddress(event.topic[1]); @@ -884,7 +849,7 @@ export class EventIndexer { if (Array.isArray(data) && data.length >= 2) { amount = data[1].toString(); } - } else if (type === "LoanApprv") { + } else if (type === 'LoanApprv') { // (type, admin), (loan_id, borrower) // topic[1] = admin address who approved the loan const data = scValToNative(event.value); @@ -893,7 +858,7 @@ export class EventIndexer { address = data[1].toString(); // borrower } // adminAddress is decoded separately and attached below - } else if (type === "LoanLiquidated") { + } else if (type === 'LoanLiquidated') { // (type, loan_id, borrower, liquidator), (debt_repaid, liquidator_bonus, borrower_refund) if (!event.topic[1] || !event.topic[2]) return null; loanId = this.decodeLoanId(event.topic[1]); @@ -904,7 +869,7 @@ export class EventIndexer { // Decode admin address for LoanApprv events (topic[1] = approving admin) let adminAddress: string | undefined; - if (type === "LoanApprv" && event.topic[1]) { + if (type === 'LoanApprv' && event.topic[1]) { try { adminAddress = this.decodeAddress(event.topic[1]); } catch { @@ -919,8 +884,8 @@ export class EventIndexer { ledgerClosedAt: new Date(event.ledgerClosedAt), txHash: event.txHash, contractId: event.contractId.toString(), - topics: event.topic.map((topic) => topic.toXDR("base64")), - value: event.value.toXDR("base64"), + topics: event.topic.map((topic) => topic.toXDR('base64')), + value: event.value.toXDR('base64'), ...(amount !== undefined ? { amount } : {}), ...(loanId !== undefined ? { loanId } : {}), ...(interestRateBps !== undefined ? { interestRateBps } : {}), @@ -943,60 +908,58 @@ export class EventIndexer { updated_at = CURRENT_TIMESTAMP`, [userId, 500 + delta, delta], ); - logger.withContext().info("Updated user score from indexed event", { + logger.withContext().info('Updated user score from indexed event', { userId, delta, }); } catch (error) { - logger - .withContext() - .error("Failed to update user score", { userId, error }); + logger.withContext().error('Failed to update user score', { userId, error }); } } private async triggerNotification(event: ContractEvent): Promise { if (!event.address) return; - let type = ""; - let title = ""; - let message = ""; + let type = ''; + let title = ''; + let message = ''; switch (event.eventType) { - case "LoanApproved": - type = "loan_approved"; - title = "Loan Approved"; + case 'LoanApproved': + type = 'loan_approved'; + title = 'Loan Approved'; message = event.loanId ? `Your loan #${event.loanId} has been approved.` - : "Your loan has been approved."; + : 'Your loan has been approved.'; break; - case "LoanRepaid": - type = "repayment_confirmed"; - title = "Repayment Confirmed"; + case 'LoanRepaid': + type = 'repayment_confirmed'; + title = 'Repayment Confirmed'; message = event.loanId ? `Repayment for loan #${event.loanId} has been confirmed.` - : "Your loan repayment has been confirmed."; + : 'Your loan repayment has been confirmed.'; break; - case "LoanDefaulted": - type = "loan_defaulted"; - title = "Loan Defaulted"; + case 'LoanDefaulted': + type = 'loan_defaulted'; + title = 'Loan Defaulted'; message = event.loanId ? `Loan #${event.loanId} has been marked as defaulted.` - : "A loan has been marked as defaulted."; + : 'A loan has been marked as defaulted.'; break; - case "CollateralLiquidated": - type = "loan_defaulted"; - title = "Collateral Seized"; + case 'CollateralLiquidated': + type = 'loan_defaulted'; + title = 'Collateral Seized'; message = event.loanId ? `Collateral for loan #${event.loanId} has been seized due to default.` - : "Collateral has been seized due to a loan default."; + : 'Collateral has been seized due to a loan default.'; break; - case "LoanLiquidated": { - type = "loan_liquidated"; - title = "Loan Liquidated"; + case 'LoanLiquidated': { + type = 'loan_liquidated'; + title = 'Loan Liquidated'; const refundPart = event.borrowerRefund && BigInt(event.borrowerRefund) > 0n ? `A refund of ${event.borrowerRefund} has been returned to you.` - : "No refund is owed."; + : 'No refund is owed.'; message = event.loanId ? `Loan #${event.loanId} has been liquidated. Your debt has been cleared. ${refundPart}` : `Your loan has been liquidated. Your debt has been cleared. ${refundPart}`; @@ -1017,20 +980,16 @@ export class EventIndexer { private decodeAddress(value: xdr.ScVal): string { const native = scValToNative(value); - if (typeof native !== "string") { - throw new Error( - `Expected address string, got ${typeof native}: ${String(native)}`, - ); + if (typeof native !== 'string') { + throw new Error(`Expected address string, got ${typeof native}: ${String(native)}`); } return native; } private decodeAmount(value: xdr.ScVal): string { const native = scValToNative(value); - if (typeof native !== "bigint" && typeof native !== "number") { - throw new Error( - `Expected numeric amount, got ${typeof native}: ${String(native)}`, - ); + if (typeof native !== 'bigint' && typeof native !== 'number') { + throw new Error(`Expected numeric amount, got ${typeof native}: ${String(native)}`); } return native.toString(); } @@ -1049,7 +1008,7 @@ export class EventIndexer { return undefined; } const first = native[0]; - if (typeof first === "bigint" || typeof first === "number") { + if (typeof first === 'bigint' || typeof first === 'number') { return first.toString(); } return undefined; @@ -1061,7 +1020,7 @@ export class EventIndexer { return undefined; } const second = native[1]; - if (typeof second === "bigint" || typeof second === "number") { + if (typeof second === 'bigint' || typeof second === 'number') { return second.toString(); } return undefined; @@ -1073,7 +1032,7 @@ export class EventIndexer { return undefined; } const third = native[2]; - if (typeof third === "bigint" || typeof third === "number") { + if (typeof third === 'bigint' || typeof third === 'number') { return third.toString(); } return undefined; @@ -1085,7 +1044,7 @@ export class EventIndexer { return undefined; } const second = native[1]; - if (typeof second === "string") { + if (typeof second === 'string') { return second; } return undefined; @@ -1095,17 +1054,14 @@ export class EventIndexer { return ADMIN_CONFIG_EVENT_TYPES.has(eventType); } - private async quarantineEvent( - event: SorobanRawEvent, - error: unknown, - ): Promise { + private async quarantineEvent(event: SorobanRawEvent, error: unknown): Promise { const errorMessage = error instanceof Error ? error.message : String(error); let rawTopics: string[] = []; - let rawValue = ""; + let rawValue = ''; try { - rawTopics = event.topic.map((t) => t.toXDR("base64")); - rawValue = event.value.toXDR("base64"); + rawTopics = event.topic.map((t) => t.toXDR('base64')); + rawValue = event.value.toXDR('base64'); } catch { // XDR serialisation itself failed; store empty strings so the row is // still inserted and the error_message captures the original failure. @@ -1121,7 +1077,7 @@ export class EventIndexer { contractId: event.contractId, }; - logger.withContext().warn("Quarantining malformed event", { + logger.withContext().warn('Quarantining malformed event', { eventId: event.id, ledger: event.ledger, txHash: event.txHash, @@ -1144,7 +1100,7 @@ export class EventIndexer { ], ); } catch (dbError) { - logger.withContext().error("Failed to quarantine malformed event", { + logger.withContext().error('Failed to quarantine malformed event', { eventId: event.id, dbError, }); @@ -1153,15 +1109,12 @@ export class EventIndexer { private async logQuarantineGrowth(newlyQuarantined: number): Promise { try { - const result = await query( - "SELECT COUNT(*)::int AS count FROM quarantine_events", - [], - ); + const result = await query('SELECT COUNT(*)::int AS count FROM quarantine_events', []); const totalCount = Number(result.rows[0]?.count ?? 0); const previousCount = this.lastObservedQuarantineCount; if (totalCount > previousCount) { - logger.withContext().warn("Quarantine event count increased", { + logger.withContext().warn('Quarantine event count increased', { previousCount, totalCount, delta: totalCount - previousCount, @@ -1172,35 +1125,27 @@ export class EventIndexer { previousCount < this.quarantineAlertThreshold && totalCount >= this.quarantineAlertThreshold ) { - logger - .withContext() - .error("Quarantine event count exceeded alert threshold", { - threshold: this.quarantineAlertThreshold, - totalCount, - }); + logger.withContext().error('Quarantine event count exceeded alert threshold', { + threshold: this.quarantineAlertThreshold, + totalCount, + }); } } this.lastObservedQuarantineCount = Math.max(previousCount, totalCount); } catch (error) { - logger - .withContext() - .error("Failed to check quarantine event count", { error }); + logger.withContext().error('Failed to check quarantine event count', { error }); } } - private decodeEventType( - value: xdr.ScVal | undefined, - ): WebhookEventType | null { + private decodeEventType(value: xdr.ScVal | undefined): WebhookEventType | null { if (!value) return null; try { const rawType = value.sym().toString(); const normalizedType = EVENT_TYPE_ALIASES[rawType] ?? rawType; - return SUPPORTED_WEBHOOK_EVENT_TYPES.includes( - normalizedType as WebhookEventType, - ) + return SUPPORTED_WEBHOOK_EVENT_TYPES.includes(normalizedType as WebhookEventType) ? (normalizedType as WebhookEventType) : null; } catch { diff --git a/backend/src/services/eventStreamService.ts b/backend/src/services/eventStreamService.ts index dd64de1e..e05f37e1 100644 --- a/backend/src/services/eventStreamService.ts +++ b/backend/src/services/eventStreamService.ts @@ -1,5 +1,5 @@ -import type { Response } from "express"; -import logger from "../utils/logger.js"; +import type { Response } from 'express'; +import logger from '../utils/logger.js'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -49,14 +49,14 @@ class EventStreamService { const allClients = this.collectAllClients(); for (const clientInfo of allClients) { try { - clientInfo.res.write(": ping\n\n"); + clientInfo.res.write(': ping\n\n'); } catch { this.removeClient(clientInfo); } } const counts = this.getConnectionCount(); - logger.withContext().info("SSE heartbeat", { + logger.withContext().info('SSE heartbeat', { borrower: counts.borrower, admin: counts.admin, total: counts.total, @@ -109,9 +109,7 @@ class EventStreamService { sendEvent(res: SseClient, event: LoanEventPayload): void { const payload = - `id: ${event.eventId}\n` + - `event: loan-event\n` + - `data: ${JSON.stringify(event)}\n\n`; + `id: ${event.eventId}\n` + `event: loan-event\n` + `data: ${JSON.stringify(event)}\n\n`; res.write(payload); } @@ -151,11 +149,7 @@ class EventStreamService { * Registers an SSE client for a specific borrower's events. * Returns an unsubscribe function for cleanup on disconnect. */ - subscribeAddress( - userKey: string, - address: string, - res: SseClient, - ): () => void { + subscribeAddress(userKey: string, address: string, res: SseClient): () => void { if (!borrowerClients.has(address)) { borrowerClients.set(address, new Set()); } @@ -164,7 +158,7 @@ class EventStreamService { this.registerUserClient(userKey, res); this.startHeartbeat(); - logger.withContext().info("SSE client subscribed to borrower events", { + logger.withContext().info('SSE client subscribed to borrower events', { address, userKey, activeConnections: this.getUserConnectionCount(userKey), @@ -177,7 +171,7 @@ class EventStreamService { } this.unregisterUserClient(userKey, res); this.stopHeartbeatIfEmpty(); - logger.withContext().info("SSE client unsubscribed from address events", { + logger.withContext().info('SSE client unsubscribed from address events', { address, userKey, activeConnections: this.getUserConnectionCount(userKey), @@ -195,7 +189,7 @@ class EventStreamService { this.registerUserClient(userKey, res); this.startHeartbeat(); - logger.withContext().info("SSE admin client subscribed to all events", { + logger.withContext().info('SSE admin client subscribed to all events', { userKey, activeConnections: this.getUserConnectionCount(userKey), }); @@ -204,12 +198,10 @@ class EventStreamService { adminClients.delete(clientInfo); this.unregisterUserClient(userKey, res); this.stopHeartbeatIfEmpty(); - logger - .withContext() - .info("SSE admin client unsubscribed from all events", { - userKey, - activeConnections: this.getUserConnectionCount(userKey), - }); + logger.withContext().info('SSE admin client unsubscribed from all events', { + userKey, + activeConnections: this.getUserConnectionCount(userKey), + }); }; } @@ -229,7 +221,7 @@ class EventStreamService { // Verify user identity before sending (fixes #471) this.sendEvent(clientInfo.res, event); } catch (err) { - logger.withContext().error("SSE write error (address)", { + logger.withContext().error('SSE write error (address)', { address: event.address, userKey: clientInfo.userKey, err, @@ -250,7 +242,7 @@ class EventStreamService { try { this.sendEvent(clientInfo.res, event); } catch (err) { - logger.withContext().error("SSE write error (admin)", { + logger.withContext().error('SSE write error (admin)', { userKey: clientInfo.userKey, err, }); @@ -276,7 +268,7 @@ class EventStreamService { }; } - closeAllConnections(message = "Server shutting down"): void { + closeAllConnections(message = 'Server shutting down'): void { this.stopHeartbeat(); const clients = new Set(); @@ -291,14 +283,13 @@ class EventStreamService { } const shutdownPayload = - `event: shutdown\n` + - `data: ${JSON.stringify({ type: "shutdown", message })}\n\n`; + `event: shutdown\n` + `data: ${JSON.stringify({ type: 'shutdown', message })}\n\n`; for (const clientInfo of clients) { try { clientInfo.res.write(shutdownPayload); } catch (err) { - logger.withContext().error("SSE shutdown write error", { + logger.withContext().error('SSE shutdown write error', { userKey: clientInfo.userKey, err, }); @@ -307,7 +298,7 @@ class EventStreamService { try { clientInfo.res.end(); } catch (err) { - logger.withContext().error("SSE shutdown close error", { + logger.withContext().error('SSE shutdown close error', { userKey: clientInfo.userKey, err, }); diff --git a/backend/src/services/indexerManager.ts b/backend/src/services/indexerManager.ts index 458f32fa..cfc16fe8 100644 --- a/backend/src/services/indexerManager.ts +++ b/backend/src/services/indexerManager.ts @@ -1,6 +1,6 @@ -import { EventIndexer } from "./eventIndexer.js"; -import logger from "../utils/logger.js"; -import { getStellarRpcUrl } from "../config/stellar.js"; +import { EventIndexer } from './eventIndexer.js'; +import logger from '../utils/logger.js'; +import { getStellarRpcUrl } from '../config/stellar.js'; let indexerInstance: EventIndexer | null = null; @@ -9,7 +9,7 @@ let indexerInstance: EventIndexer | null = null; */ export const startIndexer = (): void => { if (indexerInstance) { - logger.withContext().warn("Indexer already running"); + logger.withContext().warn('Indexer already running'); return; } @@ -17,31 +17,26 @@ export const startIndexer = (): void => { LOAN_MANAGER_CONTRACT_ID: process.env.LOAN_MANAGER_CONTRACT_ID, LENDING_POOL_CONTRACT_ID: process.env.LENDING_POOL_CONTRACT_ID, REMITTANCE_NFT_CONTRACT_ID: process.env.REMITTANCE_NFT_CONTRACT_ID, - MULTISIG_GOVERNANCE_CONTRACT_ID: - process.env.MULTISIG_GOVERNANCE_CONTRACT_ID, + MULTISIG_GOVERNANCE_CONTRACT_ID: process.env.MULTISIG_GOVERNANCE_CONTRACT_ID, }; for (const [envVar, value] of Object.entries(contractEnvMap)) { if (!value || value.trim().length === 0) { - logger.warn( - `${envVar} is not set — events for that contract will not be indexed`, - ); + logger.warn(`${envVar} is not set — events for that contract will not be indexed`); } } const contractIds = Object.values(contractEnvMap).filter((id): id is string => Boolean(id && id.trim().length > 0), ); - const pollIntervalMs = parseInt( - process.env.INDEXER_POLL_INTERVAL_MS || "30000", - ); - const batchSize = parseInt(process.env.INDEXER_BATCH_SIZE || "100"); + const pollIntervalMs = parseInt(process.env.INDEXER_POLL_INTERVAL_MS || '30000'); + const batchSize = parseInt(process.env.INDEXER_BATCH_SIZE || '100'); if (contractIds.length === 0) { logger .withContext() .warn( - "No contract IDs set for indexer. Set LOAN_MANAGER_CONTRACT_ID, LENDING_POOL_CONTRACT_ID, REMITTANCE_NFT_CONTRACT_ID, or MULTISIG_GOVERNANCE_CONTRACT_ID.", + 'No contract IDs set for indexer. Set LOAN_MANAGER_CONTRACT_ID, LENDING_POOL_CONTRACT_ID, REMITTANCE_NFT_CONTRACT_ID, or MULTISIG_GOVERNANCE_CONTRACT_ID.', ); return; } @@ -56,10 +51,10 @@ export const startIndexer = (): void => { }); indexerInstance.start().catch((error) => { - logger.withContext().error("Failed to start indexer", { error }); + logger.withContext().error('Failed to start indexer', { error }); }); - logger.withContext().info("Event indexer initialized", { + logger.withContext().info('Event indexer initialized', { rpcUrl, contractIds, pollIntervalMs, @@ -74,7 +69,7 @@ export const stopIndexer = async (): Promise => { if (indexerInstance) { await indexerInstance.stop(); indexerInstance = null; - logger.withContext().info("Event indexer stopped"); + logger.withContext().info('Event indexer stopped'); } }; diff --git a/backend/src/services/jobMetricsService.ts b/backend/src/services/jobMetricsService.ts index 9e9dd2ed..590351e5 100644 --- a/backend/src/services/jobMetricsService.ts +++ b/backend/src/services/jobMetricsService.ts @@ -51,11 +51,7 @@ class JobMetricsService { /** * Record a failed job run */ - recordFailure( - jobName: string, - error: Error | string, - durationMs: number, - ): void { + recordFailure(jobName: string, error: Error | string, durationMs: number): void { this.initializeJob(jobName); const metrics = this.metrics.get(jobName)!; metrics.lastRunAt = new Date(); diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts index ef92258b..371e10ae 100644 --- a/backend/src/services/notificationService.ts +++ b/backend/src/services/notificationService.ts @@ -1,18 +1,18 @@ -import { query } from "../db/connection.js"; -import logger from "../utils/logger.js"; -import type { Response } from "express"; +import { query } from '../db/connection.js'; +import logger from '../utils/logger.js'; +import type { Response } from 'express'; // ─── Types ──────────────────────────────────────────────────────────────────── export type NotificationType = - | "loan_approved" - | "repayment_due" - | "repayment_confirmed" - | "loan_defaulted" - | "loan_liquidated" - | "score_changed"; + | 'loan_approved' + | 'repayment_due' + | 'repayment_confirmed' + | 'loan_defaulted' + | 'loan_liquidated' + | 'score_changed'; -export type NotificationStatus = "unread" | "read" | "archived"; +export type NotificationStatus = 'unread' | 'read' | 'archived'; export interface Notification { id: number; @@ -41,7 +41,7 @@ export interface NotificationPreferences { smsEnabled: boolean; phone: string | null; perTypeOverrides: Record; - digestFrequency?: "off" | "daily" | "weekly"; + digestFrequency?: 'off' | 'daily' | 'weekly'; } // ─── SSE subscriber registry ────────────────────────────────────────────────── @@ -60,7 +60,7 @@ async function getTwilioClient() { ) { return null; } - const { default: twilio } = await import("twilio"); + const { default: twilio } = await import('twilio'); return twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); } @@ -70,7 +70,7 @@ async function ensureSendGrid() { if (_sgInitialized) return; _sgInitialized = true; if (process.env.SENDGRID_API_KEY) { - const sgMail = await import("@sendgrid/mail"); + const sgMail = await import('@sendgrid/mail'); sgMail.default.setApiKey(process.env.SENDGRID_API_KEY); } } @@ -79,46 +79,41 @@ function buildEmailTemplate( type: NotificationType, message: string, ): { subject: string; html: string } { - const templates: Record = - { - loan_approved: { - subject: "Your loan has been approved — RemitLend", - html: `

Loan Approved

${message}

Log in to view your loan details and repayment schedule.

`, - }, - repayment_due: { - subject: "Repayment reminder — RemitLend", - html: `

Repayment Due Soon

${message}

Please ensure funds are available to avoid a default.

`, - }, - repayment_confirmed: { - subject: "Repayment confirmed — RemitLend", - html: `

Repayment Confirmed

${message}

Thank you for your payment.

`, - }, - loan_defaulted: { - subject: "Loan default notice — RemitLend", - html: `

Loan Defaulted

${message}

Contact support immediately if you believe this is an error.

`, - }, - loan_liquidated: { - subject: "Your loan has been liquidated — RemitLend", - html: `

Loan Liquidated

${message}

Contact support if you have questions about the outcome.

`, - }, - score_changed: { - subject: "Your credit score has changed — RemitLend", - html: `

Credit Score Update

${message}

Log in to see your updated score and history.

`, - }, - }; + const templates: Record = { + loan_approved: { + subject: 'Your loan has been approved — RemitLend', + html: `

Loan Approved

${message}

Log in to view your loan details and repayment schedule.

`, + }, + repayment_due: { + subject: 'Repayment reminder — RemitLend', + html: `

Repayment Due Soon

${message}

Please ensure funds are available to avoid a default.

`, + }, + repayment_confirmed: { + subject: 'Repayment confirmed — RemitLend', + html: `

Repayment Confirmed

${message}

Thank you for your payment.

`, + }, + loan_defaulted: { + subject: 'Loan default notice — RemitLend', + html: `

Loan Defaulted

${message}

Contact support immediately if you believe this is an error.

`, + }, + loan_liquidated: { + subject: 'Your loan has been liquidated — RemitLend', + html: `

Loan Liquidated

${message}

Contact support if you have questions about the outcome.

`, + }, + score_changed: { + subject: 'Your credit score has changed — RemitLend', + html: `

Credit Score Update

${message}

Log in to see your updated score and history.

`, + }, + }; return templates[type]; } -async function sendEmail( - email: string, - message: string, - type?: NotificationType, -): Promise { +async function sendEmail(email: string, message: string, type?: NotificationType): Promise { const fromEmail = process.env.FROM_EMAIL; if (!fromEmail) { - logger.withContext().info("[Email] FROM_EMAIL not set", { email, message }); + logger.withContext().info('[Email] FROM_EMAIL not set', { email, message }); return; } @@ -127,27 +122,23 @@ async function sendEmail( if (!process.env.SENDGRID_API_KEY) { logger .withContext() - .info( - `[Email] SendGrid not configured. Would send to ${email}: ${message}`, - ); + .info(`[Email] SendGrid not configured. Would send to ${email}: ${message}`); return; } const template = type ? buildEmailTemplate(type, message) - : { subject: "Notification from RemitLend", html: `

${message}

` }; + : { subject: 'Notification from RemitLend', html: `

${message}

` }; try { - const sgMail = await import("@sendgrid/mail"); + const sgMail = await import('@sendgrid/mail'); await sgMail.default.send({ to: email, from: fromEmail, subject: template.subject, html: template.html, }); - logger - .withContext() - .info(`[Email] Sent to ${email}`, { subject: template.subject }); + logger.withContext().info(`[Email] Sent to ${email}`, { subject: template.subject }); } catch (error) { logger.withContext().error(`[Email] SendGrid failed for ${email}`, { error: error instanceof Error ? error.message : String(error), @@ -159,9 +150,7 @@ async function sendEmail( async function sendSMS(phone: string, message: string) { const twilioClient = await getTwilioClient(); if (!twilioClient || !process.env.TWILIO_PHONE_NUMBER) { - logger - .withContext() - .warn(`[SMS] Twilio not configured. Would send to ${phone}: ${message}`); + logger.withContext().warn(`[SMS] Twilio not configured. Would send to ${phone}: ${message}`); return; } @@ -171,9 +160,7 @@ async function sendSMS(phone: string, message: string) { from: process.env.TWILIO_PHONE_NUMBER, to: phone, }); - logger - .withContext() - .info(`[SMS] Sent to ${phone}: ${message}`, { sid: result.sid }); + logger.withContext().info(`[SMS] Sent to ${phone}: ${message}`, { sid: result.sid }); } catch (error) { logger.withContext().error(`[SMS] Failed to send to ${phone}`, { error: error instanceof Error ? error.message : String(error), @@ -184,9 +171,7 @@ async function sendSMS(phone: string, message: string) { } class NotificationService { - async getNotificationPreferences( - userId: string, - ): Promise { + async getNotificationPreferences(userId: string): Promise { const result = await query( `SELECT email_enabled, sms_enabled, phone FROM user_profiles @@ -215,10 +200,7 @@ class NotificationService { async updateNotificationPreferences( userId: string, - payload: Pick< - NotificationPreferences, - "emailEnabled" | "smsEnabled" | "phone" - >, + payload: Pick, ): Promise { const result = await query( `UPDATE user_profiles @@ -248,13 +230,10 @@ class NotificationService { * Persists a new notification and pushes it to any active SSE subscribers * for that user. */ - async createNotification( - params: CreateNotificationParams, - ): Promise { + async createNotification(params: CreateNotificationParams): Promise { const { userId, type, title, message, loanId, actionUrl } = params; - const resolvedActionUrl = - actionUrl ?? (loanId != null ? `/loans/${loanId}` : null); + const resolvedActionUrl = actionUrl ?? (loanId != null ? `/loans/${loanId}` : null); const result = await query( `INSERT INTO notifications (user_id, type, title, message, loan_id, action_url, status) @@ -278,13 +257,8 @@ class NotificationService { */ async batchRepaymentNotificationsForDigest( notifications: Array<{ userId: string; message: string; loanId?: number }>, - ): Promise< - Map> - > { - const grouped = new Map< - string, - Array<{ userId: string; message: string; loanId?: number }> - >(); + ): Promise>> { + const grouped = new Map>(); for (const notif of notifications) { const prefResult = await query( @@ -292,9 +266,9 @@ class NotificationService { [notif.userId], ); - const digestFrequency = prefResult.rows[0]?.digest_frequency ?? "off"; + const digestFrequency = prefResult.rows[0]?.digest_frequency ?? 'off'; - if (digestFrequency === "off") { + if (digestFrequency === 'off') { // Send immediately const key = `${notif.userId}:immediate`; if (!grouped.has(key)) { @@ -318,11 +292,7 @@ class NotificationService { * Sends external notifications (Email/SMS) based on user preferences. * SMS is triggered for repayment_due and loan_defaulted events. */ - private async notifyUserExternal( - userId: string, - message: string, - type: NotificationType, - ) { + private async notifyUserExternal(userId: string, message: string, type: NotificationType) { try { const result = await query( `SELECT email, phone, email_enabled, sms_enabled @@ -341,17 +311,13 @@ class NotificationService { // Trigger SMS for critical events: repayment_due, loan_defaulted, and loan_liquidated const smsEnabledForType = - type === "repayment_due" || - type === "loan_defaulted" || - type === "loan_liquidated"; + type === 'repayment_due' || type === 'loan_defaulted' || type === 'loan_liquidated'; if (user.sms_enabled && user.phone && smsEnabledForType) { await sendSMS(user.phone, message); } } catch (error) { - logger - .withContext() - .error("Error sending external notifications", { userId, error }); + logger.withContext().error('Error sending external notifications', { userId, error }); } } @@ -367,7 +333,7 @@ class NotificationService { from?: string, to?: string, ): Promise { - let whereClause = "user_id = $1"; + let whereClause = 'user_id = $1'; const params: (string | number)[] = [userId]; let paramIndex = 2; @@ -422,7 +388,7 @@ class NotificationService { `SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND status = 'unread'`, [userId], ); - return parseInt(result.rows[0]?.count ?? "0", 10); + return parseInt(result.rows[0]?.count ?? '0', 10); } /** @@ -468,11 +434,7 @@ class NotificationService { * 2. In-app SSE push to each admin wallet currently subscribed * 3. Webhook POST to ADMIN_WEBHOOK_URL (if configured) */ - async notifyAdmins(params: { - title: string; - message: string; - loanId?: number; - }): Promise { + async notifyAdmins(params: { title: string; message: string; loanId?: number }): Promise { const { title, message, loanId } = params; // 1. Email the configured admin address @@ -480,12 +442,10 @@ class NotificationService { if (adminEmail) { await sendEmail(adminEmail, message); } else { - logger - .withContext() - .warn("[Admin] ADMIN_EMAIL not set — logging dispute only", { - title, - message, - }); + logger.withContext().warn('[Admin] ADMIN_EMAIL not set — logging dispute only', { + title, + message, + }); } // 2. Push SSE notification to every admin currently connected @@ -507,11 +467,9 @@ class NotificationService { this.broadcast(adminId, notification); } } catch (err) { - logger - .withContext() - .error("[Admin] Failed to persist/push admin notifications", { - err, - }); + logger.withContext().error('[Admin] Failed to persist/push admin notifications', { + err, + }); } // 3. Optional webhook (Slack / Discord / custom) @@ -519,14 +477,12 @@ class NotificationService { if (webhookUrl) { try { await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `[RemitLend] ${title}: ${message}` }), }); } catch (err) { - logger - .withContext() - .error("[Admin] Webhook POST failed", { webhookUrl, err }); + logger.withContext().error('[Admin] Webhook POST failed', { webhookUrl, err }); } } } @@ -564,7 +520,7 @@ class NotificationService { try { res.write(data); } catch (err) { - logger.withContext().error("SSE write error", { userId, err }); + logger.withContext().error('SSE write error', { userId, err }); clients.delete(res); } } @@ -584,18 +540,13 @@ class NotificationService { ); const deletedCount = result.rowCount ?? 0; if (deletedCount > 0) { - logger - .withContext() - .info( - `Notification cleanup completed: ${deletedCount} rows deleted`, - { - retentionDays, - }, - ); + logger.withContext().info(`Notification cleanup completed: ${deletedCount} rows deleted`, { + retentionDays, + }); } return deletedCount; } catch (error) { - logger.withContext().error("Error during notification cleanup", { + logger.withContext().error('Error during notification cleanup', { error, retentionDays, }); @@ -621,19 +572,16 @@ class NotificationService { if (deletedCount > 0) { logger .withContext() - .info( - `Read/archived notification cleanup completed: ${deletedCount} rows deleted`, - { retentionDays }, - ); + .info(`Read/archived notification cleanup completed: ${deletedCount} rows deleted`, { + retentionDays, + }); } return deletedCount; } catch (error) { - logger - .withContext() - .error("Error during read/archived notification cleanup", { - error, - retentionDays, - }); + logger.withContext().error('Error during read/archived notification cleanup', { + error, + retentionDays, + }); return 0; } } @@ -642,8 +590,7 @@ class NotificationService { private mapRow(row: Record): Notification { const loanId = row.loan_id != null ? (row.loan_id as number) : undefined; - const actionUrl = - row.action_url != null ? (row.action_url as string) : undefined; + const actionUrl = row.action_url != null ? (row.action_url as string) : undefined; const base = { id: row.id as number, userId: row.user_id as string, @@ -651,8 +598,7 @@ class NotificationService { title: row.title as string, message: row.message as string, read: row.read as boolean, - status: - (row.status as NotificationStatus) ?? (row.read ? "read" : "unread"), + status: (row.status as NotificationStatus) ?? (row.read ? 'read' : 'unread'), createdAt: new Date(row.created_at as string), }; // Keep optional fields omitted rather than null so the mapped shape is @@ -672,14 +618,8 @@ let cleanupInterval: ReturnType | undefined; export function startNotificationCleanupScheduler(): void { if (cleanupInterval) return; - const retentionDays = parseInt( - process.env.NOTIFICATION_RETENTION_DAYS || "90", - 10, - ); - const readRetentionDays = parseInt( - process.env.READ_NOTIFICATION_RETENTION_DAYS || "30", - 10, - ); + const retentionDays = parseInt(process.env.NOTIFICATION_RETENTION_DAYS || '90', 10); + const readRetentionDays = parseInt(process.env.READ_NOTIFICATION_RETENTION_DAYS || '30', 10); const intervalMs = parseInt( process.env.NOTIFICATION_CLEANUP_INTERVAL_MS || String(24 * 60 * 60 * 1000), // Default: 24h 10, @@ -694,7 +634,7 @@ export function startNotificationCleanupScheduler(): void { await notificationService.deleteReadAndArchived(readRetentionDays); }, intervalMs); - logger.withContext().info("Notification cleanup scheduler started", { + logger.withContext().info('Notification cleanup scheduler started', { retentionDays, readRetentionDays, intervalMs, @@ -708,6 +648,6 @@ export function stopNotificationCleanupScheduler(): void { if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = undefined; - logger.withContext().info("Notification cleanup scheduler stopped"); + logger.withContext().info('Notification cleanup scheduler stopped'); } } diff --git a/backend/src/services/rateLimitService.ts b/backend/src/services/rateLimitService.ts index 5dac7853..4634841d 100644 --- a/backend/src/services/rateLimitService.ts +++ b/backend/src/services/rateLimitService.ts @@ -1,7 +1,7 @@ -import { createClient, type RedisClientType } from "redis"; -import logger from "../utils/logger.js"; +import { createClient, type RedisClientType } from 'redis'; +import logger from '../utils/logger.js'; -const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; +const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; interface RateLimitConfig { maxRequests: number; @@ -30,13 +30,13 @@ class RateLimitService { constructor() { this.client = createClient({ url: REDIS_URL }); - this.client.on("error", (error) => { + this.client.on('error', (error) => { this.isConnected = false; - if (process.env.NODE_ENV !== "test") { - logger.withContext().error("Rate limit Redis client error", { error }); + if (process.env.NODE_ENV !== 'test') { + logger.withContext().error('Rate limit Redis client error', { error }); } }); - this.client.on("connect", () => { + this.client.on('connect', () => { this.isConnected = true; }); } @@ -85,9 +85,7 @@ class RateLimitService { currentCount, }; } catch (error) { - logger - .withContext() - .error("Rate limit check failed", { identifier, error }); + logger.withContext().error('Rate limit check failed', { identifier, error }); // Fail open: allow the request if Redis is unavailable // This prevents the entire service from failing due to rate limiting issues @@ -111,11 +109,9 @@ class RateLimitService { try { await this.ensureConnected(); await this.client.del(key); - logger.withContext().info("Rate limit reset", { identifier }); + logger.withContext().info('Rate limit reset', { identifier }); } catch (error) { - logger - .withContext() - .error("Failed to reset rate limit", { identifier, error }); + logger.withContext().error('Failed to reset rate limit', { identifier, error }); } } @@ -129,7 +125,7 @@ class RateLimitService { async getRateLimitStatus( identifier: string, config: RateLimitConfig = RateLimitService.DEFAULT_CONFIG, - ): Promise> { + ): Promise> { const key = `rate_limit:${identifier}`; try { @@ -168,9 +164,7 @@ class RateLimitService { resetTime, }; } catch (error) { - logger - .withContext() - .error("Failed to get rate limit status", { identifier, error }); + logger.withContext().error('Failed to get rate limit status', { identifier, error }); // Return conservative values on error return { diff --git a/backend/src/services/remittanceService.ts b/backend/src/services/remittanceService.ts index 3762c08d..77a1b866 100644 --- a/backend/src/services/remittanceService.ts +++ b/backend/src/services/remittanceService.ts @@ -1,18 +1,10 @@ -import crypto from "crypto"; -import { - Asset, - Networks, - Operation, - TransactionBuilder, -} from "@stellar/stellar-sdk"; -import { - createSorobanRpcServer, - getStellarNetworkPassphrase, -} from "../config/stellar.js"; -import { query } from "../db/connection.js"; -import { withTransaction } from "../db/transaction.js"; -import { AppError } from "../errors/AppError.js"; -import logger from "../utils/logger.js"; +import crypto from 'crypto'; +import { Asset, Networks, Operation, TransactionBuilder } from '@stellar/stellar-sdk'; +import { createSorobanRpcServer, getStellarNetworkPassphrase } from '../config/stellar.js'; +import { query } from '../db/connection.js'; +import { withTransaction } from '../db/transaction.js'; +import { AppError } from '../errors/AppError.js'; +import logger from '../utils/logger.js'; export interface CreateRemittancePayload { recipientAddress: string; @@ -31,7 +23,7 @@ export interface Remittance { fromCurrency: string; toCurrency: string; memo?: string; - status: "pending" | "processing" | "completed" | "failed"; + status: 'pending' | 'processing' | 'completed' | 'failed'; transactionHash?: string; xdr?: string; createdAt: string; @@ -42,18 +34,17 @@ export interface Remittance { * Validates a Stellar public key format */ function isValidStellarAddress(address: string): boolean { - if (!address || typeof address !== "string") return false; - if (address.length !== 56 || !address.startsWith("G")) return false; + if (!address || typeof address !== 'string') return false; + if (address.length !== 56 || !address.startsWith('G')) return false; return /^G[A-Z2-7]{55}$/.test(address); } -const normalizeCurrency = (currency: string): string => - currency.trim().toUpperCase(); +const normalizeCurrency = (currency: string): string => currency.trim().toUpperCase(); const getCurrencyAsset = (currency: string): Asset => { const normalized = normalizeCurrency(currency); - if (normalized === "XLM") { + if (normalized === 'XLM') { return Asset.native(); } @@ -81,9 +72,7 @@ export const remittanceService = { /** * Create a new remittance record and generate XDR */ - async createRemittance( - payload: CreateRemittancePayload, - ): Promise { + async createRemittance(payload: CreateRemittancePayload): Promise { const id = crypto.randomUUID(); const now = new Date().toISOString(); @@ -91,14 +80,12 @@ export const remittanceService = { // while doing synchronous checks. if (!isValidStellarAddress(payload.recipientAddress)) { throw AppError.badRequest( - "Invalid Stellar recipient address (must be 56 chars, start with G)", + 'Invalid Stellar recipient address (must be 56 chars, start with G)', ); } if (!isValidStellarAddress(payload.senderAddress)) { - throw AppError.badRequest( - "Invalid Stellar sender address (must be 56 chars, start with G)", - ); + throw AppError.badRequest('Invalid Stellar sender address (must be 56 chars, start with G)'); } const paymentAsset = getCurrencyAsset(payload.fromCurrency); @@ -115,7 +102,7 @@ export const remittanceService = { const sourceAccount = await server.getAccount(payload.senderAddress); const transaction = new TransactionBuilder(sourceAccount, { - fee: "100", + fee: '100', networkPassphrase: networkPassphrase || Networks.TESTNET, }) .addOperation( @@ -146,7 +133,7 @@ export const remittanceService = { normalizedFromCurrency, normalizedToCurrency, payload.memo || null, - "pending", + 'pending', xdr, now, now, @@ -154,7 +141,7 @@ export const remittanceService = { ); if (!result.rows[0]) { - throw AppError.internal("Failed to create remittance record"); + throw AppError.internal('Failed to create remittance record'); } const record = result.rows[0]; @@ -175,11 +162,11 @@ export const remittanceService = { }; }); } catch (error) { - logger.withContext().error("Error creating remittance:", error); + logger.withContext().error('Error creating remittance:', error); if (error instanceof AppError) throw error; - throw AppError.internal("Failed to create remittance"); + throw AppError.internal('Failed to create remittance'); } }, @@ -197,7 +184,7 @@ export const remittanceService = { nextCursor: string | null; }> { try { - let whereClause = "sender_id = $1"; + let whereClause = 'sender_id = $1'; const params: (string | number)[] = [userId]; let paramIndex = 2; @@ -236,7 +223,7 @@ export const remittanceService = { const cursorValue = cursor ? new Date(cursor) : null; if (cursor && (!cursorValue || Number.isNaN(cursorValue.getTime()))) { - throw AppError.badRequest("Invalid cursor"); + throw AppError.badRequest('Invalid cursor'); } if (cursorValue) { @@ -277,35 +264,30 @@ export const remittanceService = { })); const lastRemittance = - remittances.length > 0 - ? remittances[remittances.length - 1] - : undefined; - const nextCursor = - hasNext && lastRemittance ? lastRemittance.createdAt : null; + remittances.length > 0 ? remittances[remittances.length - 1] : undefined; + const nextCursor = hasNext && lastRemittance ? lastRemittance.createdAt : null; return { remittances, - total: parseInt(countResult.rows[0]?.total || "0", 10), + total: parseInt(countResult.rows[0]?.total || '0', 10), nextCursor, }; } catch (error) { - logger.withContext().error("Error fetching remittances:", error); + logger.withContext().error('Error fetching remittances:', error); if (error instanceof AppError) { throw error; } - throw AppError.internal("Failed to fetch remittances"); + throw AppError.internal('Failed to fetch remittances'); } }, async getRemittance(id: string): Promise { try { - const result = await query("SELECT * FROM remittances WHERE id = $1", [ - id, - ]); + const result = await query('SELECT * FROM remittances WHERE id = $1', [id]); - if (!result.rows[0]) throw AppError.notFound("Remittance not found"); + if (!result.rows[0]) throw AppError.notFound('Remittance not found'); const r = result.rows[0]; @@ -324,19 +306,19 @@ export const remittanceService = { updatedAt: r.updated_at.toISOString(), }; } catch (error) { - logger.withContext().error("Error fetching remittance:", error); + logger.withContext().error('Error fetching remittance:', error); if (error instanceof AppError) { throw error; } - throw AppError.internal("Failed to fetch remittance"); + throw AppError.internal('Failed to fetch remittance'); } }, async updateRemittanceStatus( id: string, - status: "processing" | "completed" | "failed", + status: 'processing' | 'completed' | 'failed', transactionHash?: string, errorMessage?: string, ): Promise { @@ -346,17 +328,11 @@ export const remittanceService = { SET status = $1, transaction_hash = $2, error_message = $3, updated_at = $4 WHERE id = $5 RETURNING *`, - [ - status, - transactionHash || null, - errorMessage || null, - new Date().toISOString(), - id, - ], + [status, transactionHash || null, errorMessage || null, new Date().toISOString(), id], ); if (!result.rows[0]) { - throw AppError.notFound("Remittance not found"); + throw AppError.notFound('Remittance not found'); } const r = result.rows[0]; @@ -376,13 +352,13 @@ export const remittanceService = { updatedAt: r.updated_at.toISOString(), }; } catch (error) { - logger.withContext().error("Error updating remittance:", error); + logger.withContext().error('Error updating remittance:', error); if (error instanceof AppError) { throw error; } - throw AppError.internal("Failed to update remittance"); + throw AppError.internal('Failed to update remittance'); } }, }; diff --git a/backend/src/services/scoreDecayService.ts b/backend/src/services/scoreDecayService.ts index 8da185bd..2aaa40d3 100644 --- a/backend/src/services/scoreDecayService.ts +++ b/backend/src/services/scoreDecayService.ts @@ -1,7 +1,7 @@ // Service for score decay logic // Provides functions to find inactive borrowers and apply score decay -import { query } from "../db/connection.js"; +import { query } from '../db/connection.js'; const DECAY_PER_MONTH = 5; const MIN_SCORE = 300; // Adjust as needed diff --git a/backend/src/services/scoreReconciliationService.ts b/backend/src/services/scoreReconciliationService.ts index 9e38f97d..7f02bc63 100644 --- a/backend/src/services/scoreReconciliationService.ts +++ b/backend/src/services/scoreReconciliationService.ts @@ -1,9 +1,9 @@ -import { query } from "../db/connection.js"; -import { setAbsoluteUserScoresBulk } from "./scoresService.js"; -import { sorobanService } from "./sorobanService.js"; -import { recordScoreReconciliationRun } from "../middleware/metrics.js"; -import { jobMetricsService } from "./jobMetricsService.js"; -import logger from "../utils/logger.js"; +import { query } from '../db/connection.js'; +import { setAbsoluteUserScoresBulk } from './scoresService.js'; +import { sorobanService } from './sorobanService.js'; +import { recordScoreReconciliationRun } from '../middleware/metrics.js'; +import { jobMetricsService } from './jobMetricsService.js'; +import logger from '../utils/logger.js'; interface ActiveBorrowerScoreRow { address: string; @@ -29,23 +29,20 @@ export interface ScoreReconciliationResult { } function parsePositiveInt(value: string | undefined, fallback: number): number { - const parsed = Number.parseInt(value ?? "", 10); + const parsed = Number.parseInt(value ?? '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } -function parseNonNegativeInt( - value: string | undefined, - fallback: number, -): number { - const parsed = Number.parseInt(value ?? "", 10); +function parseNonNegativeInt(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value ?? '', 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { if (value == null) return fallback; const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; return fallback; } @@ -64,24 +61,15 @@ class ScoreReconciliationService { } private getMaxBorrowersPerRun(): number { - return parsePositiveInt( - process.env.SCORE_RECONCILIATION_MAX_BORROWERS_PER_RUN, - 500, - ); + return parsePositiveInt(process.env.SCORE_RECONCILIATION_MAX_BORROWERS_PER_RUN, 500); } private isAutoCorrectEnabled(): boolean { - return parseBoolean( - process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED, - false, - ); + return parseBoolean(process.env.SCORE_RECONCILIATION_AUTOCORRECT_ENABLED, false); } private getAutoCorrectThreshold(): number { - return parseNonNegativeInt( - process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD, - 50, - ); + return parseNonNegativeInt(process.env.SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD, 50); } private async fetchActiveBorrowerScores(): Promise { @@ -119,7 +107,7 @@ class ScoreReconciliationService { }; return { - address: String(record.address ?? ""), + address: String(record.address ?? ''), dbScore: record.current_score === null || record.current_score === undefined ? null @@ -130,7 +118,7 @@ class ScoreReconciliationService { async reconcileActiveBorrowerScores(): Promise { const startTime = Date.now(); - const jobName = "scoreReconciliationService"; + const jobName = 'scoreReconciliationService'; const activeBorrowers = await this.fetchActiveBorrowerScores(); const batchSize = this.getBatchSize(); @@ -141,7 +129,7 @@ class ScoreReconciliationService { let checkedBorrowerCount = 0; let failedBorrowerCount = 0; - logger.withContext().info("score_reconciliation.run.start", { + logger.withContext().info('score_reconciliation.run.start', { activeBorrowerCount: activeBorrowers.length, batchSize, autoCorrectEnabled, @@ -152,9 +140,7 @@ class ScoreReconciliationService { for (const batch of chunk(activeBorrowers, batchSize)) { const batchResults = await Promise.allSettled( batch.map(async (borrowerRow) => { - const contractScore = await sorobanService.getOnChainCreditScore( - borrowerRow.address, - ); + const contractScore = await sorobanService.getOnChainCreditScore(borrowerRow.address); return { ...borrowerRow, contractScore, @@ -163,10 +149,10 @@ class ScoreReconciliationService { ); batchResults.forEach((result, index) => { - const address = batch[index]?.address ?? "unknown"; - if (result.status === "rejected") { + const address = batch[index]?.address ?? 'unknown'; + if (result.status === 'rejected') { failedBorrowerCount += 1; - logger.withContext().error("score_reconciliation.borrower.failed", { + logger.withContext().error('score_reconciliation.borrower.failed', { address, error: result.reason, }); @@ -175,8 +161,7 @@ class ScoreReconciliationService { checkedBorrowerCount += 1; const { dbScore, contractScore } = result.value; - const absoluteDifference = - dbScore === null ? null : Math.abs(contractScore - dbScore); + const absoluteDifference = dbScore === null ? null : Math.abs(contractScore - dbScore); const isDivergent = dbScore === null || dbScore !== contractScore; if (!isDivergent) { @@ -191,13 +176,10 @@ class ScoreReconciliationService { }; divergences.push(divergence); - logger - .withContext() - .warn("score_reconciliation.mismatch", divergence); + logger.withContext().warn('score_reconciliation.mismatch', divergence); const exceedsThreshold = - absoluteDifference === null || - absoluteDifference >= autoCorrectThreshold; + absoluteDifference === null || absoluteDifference >= autoCorrectThreshold; if (autoCorrectEnabled && exceedsThreshold) { corrections.set(address, contractScore); @@ -205,14 +187,14 @@ class ScoreReconciliationService { }); } - logger.withContext().info("score_divergence_count", { - metric: "score_divergence_count", + logger.withContext().info('score_divergence_count', { + metric: 'score_divergence_count', value: divergences.length, }); if (corrections.size > 0) { await setAbsoluteUserScoresBulk(corrections); - logger.withContext().warn("score_reconciliation.autocorrect.applied", { + logger.withContext().warn('score_reconciliation.autocorrect.applied', { correctedCount: corrections.size, threshold: autoCorrectThreshold, }); @@ -229,7 +211,7 @@ class ScoreReconciliationService { divergences, }; - logger.withContext().info("score_reconciliation.run.complete", { + logger.withContext().info('score_reconciliation.run.complete', { activeBorrowerCount: result.activeBorrowerCount, checkedBorrowerCount: result.checkedBorrowerCount, failedBorrowerCount: result.failedBorrowerCount, @@ -246,11 +228,7 @@ class ScoreReconciliationService { } catch (error) { // Record failure metrics const durationMs = Date.now() - startTime; - jobMetricsService.recordFailure( - jobName, - error as Error | string, - durationMs, - ); + jobMetricsService.recordFailure(jobName, error as Error | string, durationMs); throw error; } } @@ -264,31 +242,24 @@ let reconciliationInFlight = false; export function startScoreReconciliationScheduler(): void { if (reconciliationInterval) return; - if (process.env.NODE_ENV === "test") { + if (process.env.NODE_ENV === 'test') { return; } if (!process.env.REMITTANCE_NFT_CONTRACT_ID) { logger .withContext() - .warn( - "Score reconciliation scheduler disabled (set REMITTANCE_NFT_CONTRACT_ID)", - ); + .warn('Score reconciliation scheduler disabled (set REMITTANCE_NFT_CONTRACT_ID)'); return; } - const intervalMs = parsePositiveInt( - process.env.SCORE_RECONCILIATION_INTERVAL_MS, - 60 * 60 * 1000, - ); + const intervalMs = parsePositiveInt(process.env.SCORE_RECONCILIATION_INTERVAL_MS, 60 * 60 * 1000); const run = async () => { if (reconciliationInFlight) { logger .withContext() - .warn( - "Score reconciliation run skipped because a previous run is still in flight", - ); + .warn('Score reconciliation run skipped because a previous run is still in flight'); return; } @@ -296,9 +267,7 @@ export function startScoreReconciliationScheduler(): void { try { await scoreReconciliationService.reconcileActiveBorrowerScores(); } catch (error) { - logger - .withContext() - .error("Score reconciliation scheduled run failed", { error }); + logger.withContext().error('Score reconciliation scheduled run failed', { error }); } finally { reconciliationInFlight = false; } @@ -311,7 +280,7 @@ export function startScoreReconciliationScheduler(): void { }, intervalMs); reconciliationInterval.unref?.(); - logger.withContext().info("Score reconciliation scheduler started", { + logger.withContext().info('Score reconciliation scheduler started', { intervalMs, }); } @@ -320,6 +289,6 @@ export function stopScoreReconciliationScheduler(): void { if (reconciliationInterval) { clearInterval(reconciliationInterval); reconciliationInterval = undefined; - logger.withContext().info("Score reconciliation scheduler stopped"); + logger.withContext().info('Score reconciliation scheduler stopped'); } } diff --git a/backend/src/services/scoresService.ts b/backend/src/services/scoresService.ts index 8974a223..a126a248 100644 --- a/backend/src/services/scoresService.ts +++ b/backend/src/services/scoresService.ts @@ -1,6 +1,6 @@ -import { cacheService } from "./cacheService.js"; -import { type PoolClient, query } from "../db/connection.js"; -import logger from "../utils/logger.js"; +import { cacheService } from './cacheService.js'; +import { type PoolClient, query } from '../db/connection.js'; +import logger from '../utils/logger.js'; /** * Apply multiple user score deltas atomically. @@ -50,7 +50,7 @@ export async function updateUserScoresBulk( } else { await query(sql, params); } - logger.withContext().info("Applied bulk user score updates", { + logger.withContext().info('Applied bulk user score updates', { updatedCount: params.length / 2, }); @@ -60,9 +60,7 @@ export async function updateUserScoresBulk( await cacheService.delete(`score:breakdown:${userId}`); } } catch (error) { - logger - .withContext() - .error("Failed to apply bulk user score updates", { error }); + logger.withContext().error('Failed to apply bulk user score updates', { error }); throw error; } } @@ -71,9 +69,7 @@ export async function updateUserScoresBulk( * Set multiple user scores to authoritative absolute values in a single query. * Used by reconciliation paths where on-chain state should overwrite DB state. */ -export async function setAbsoluteUserScoresBulk( - scores: Map, -): Promise { +export async function setAbsoluteUserScoresBulk(scores: Map): Promise { if (!scores || scores.size === 0) return; const params: (string | number)[] = []; @@ -91,7 +87,7 @@ export async function setAbsoluteUserScoresBulk( const sql = ` WITH reconciled_scores (user_id, current_score) AS ( - VALUES ${valuePlaceholders.join(",")} + VALUES ${valuePlaceholders.join(',')} ) INSERT INTO scores (user_id, current_score) SELECT user_id, current_score FROM reconciled_scores @@ -103,11 +99,9 @@ export async function setAbsoluteUserScoresBulk( try { await query(sql, params); - logger - .withContext() - .info("Applied absolute user score reconciliation updates", { - updatedCount: valuePlaceholders.length, - }); + logger.withContext().info('Applied absolute user score reconciliation updates', { + updatedCount: valuePlaceholders.length, + }); // Invalidate Redis cache for reconciled users for (const [userId] of scores) { @@ -117,11 +111,9 @@ export async function setAbsoluteUserScoresBulk( } } } catch (error) { - logger - .withContext() - .error("Failed to apply absolute user score reconciliation updates", { - error, - }); + logger.withContext().error('Failed to apply absolute user score reconciliation updates', { + error, + }); throw error; } } diff --git a/backend/src/services/sorobanService.ts b/backend/src/services/sorobanService.ts index 0adae399..e1e29b32 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -7,14 +7,14 @@ import { Address, StrKey, Keypair, -} from "@stellar/stellar-sdk"; -import logger from "../utils/logger.js"; -import { AppError } from "../errors/AppError.js"; +} from '@stellar/stellar-sdk'; +import logger from '../utils/logger.js'; +import { AppError } from '../errors/AppError.js'; import { createSorobanRpcServer, getStellarNetworkPassphrase, getStellarRpcUrl, -} from "../config/stellar.js"; +} from '../config/stellar.js'; /** * Service for building and submitting Soroban contract transactions. @@ -28,9 +28,9 @@ class SorobanService { return createSorobanRpcServer(); } - async ping(): Promise<"ok" | "error"> { + async ping(): Promise<'ok' | 'error'> { const result = await this.healthCheck(); - return result.connected ? "ok" : "error"; + return result.connected ? 'ok' : 'error'; } async buildCancelLoanTx( @@ -44,9 +44,9 @@ class SorobanService { const account = await server.getAccount(borrower); const borrowerScVal = nativeToScVal(Address.fromString(borrower), { - type: "address", + type: 'address', }); - const loanIdScVal = nativeToScVal(loanId, { type: "symbol" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'symbol' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -55,7 +55,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "cancel_loan", + function: 'cancel_loan', args: [borrowerScVal, loanIdScVal], }), ) @@ -80,10 +80,10 @@ class SorobanService { const account = await server.getAccount(adminPublicKey); const adminScVal = nativeToScVal(Address.fromString(adminPublicKey), { - type: "address", + type: 'address', }); - const loanIdScVal = nativeToScVal(loanId, { type: "symbol" }); - const reasonScVal = nativeToScVal(reason, { type: "string" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'symbol' }); + const reasonScVal = nativeToScVal(reason, { type: 'string' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -92,7 +92,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "reject_loan", + function: 'reject_loan', args: [adminScVal, loanIdScVal, reasonScVal], }), ) @@ -112,7 +112,7 @@ class SorobanService { private getLoanManagerContractId(): string { const contractId = process.env.LOAN_MANAGER_CONTRACT_ID; if (!contractId) { - throw AppError.internal("LOAN_MANAGER_CONTRACT_ID is not configured"); + throw AppError.internal('LOAN_MANAGER_CONTRACT_ID is not configured'); } return contractId; } @@ -120,7 +120,7 @@ class SorobanService { private getLendingPoolContractId(): string { const contractId = process.env.LENDING_POOL_CONTRACT_ID; if (!contractId) { - throw AppError.internal("LENDING_POOL_CONTRACT_ID is not configured"); + throw AppError.internal('LENDING_POOL_CONTRACT_ID is not configured'); } return contractId; } @@ -128,7 +128,7 @@ class SorobanService { private getPoolTokenAddress(): string { const address = process.env.POOL_TOKEN_ADDRESS; if (!address) { - throw AppError.internal("POOL_TOKEN_ADDRESS is not configured"); + throw AppError.internal('POOL_TOKEN_ADDRESS is not configured'); } return address; } @@ -136,35 +136,29 @@ class SorobanService { private getRemittanceNftContractId(): string { const contractId = process.env.REMITTANCE_NFT_CONTRACT_ID; if (!contractId) { - throw AppError.internal("REMITTANCE_NFT_CONTRACT_ID is not configured"); + throw AppError.internal('REMITTANCE_NFT_CONTRACT_ID is not configured'); } return contractId; } private getScoreReadSourceKeypair(): Keypair { const secret = - process.env.SCORE_RECONCILIATION_SOURCE_SECRET ?? - process.env.LOAN_MANAGER_ADMIN_SECRET; + process.env.SCORE_RECONCILIATION_SOURCE_SECRET ?? process.env.LOAN_MANAGER_ADMIN_SECRET; if (!secret) { - throw AppError.internal( - "A source secret is required for score reconciliation reads", - ); + throw AppError.internal('A source secret is required for score reconciliation reads'); } try { return Keypair.fromSecret(secret); } catch { - throw AppError.internal( - "The configured score reconciliation source secret is invalid", - ); + throw AppError.internal('The configured score reconciliation source secret is invalid'); } } private getDefaultCreditScore(): number { const configured = Number.parseInt( - process.env.DEFAULT_CREDIT_SCORE ?? - String(SorobanService.FALLBACK_CREDIT_SCORE), + process.env.DEFAULT_CREDIT_SCORE ?? String(SorobanService.FALLBACK_CREDIT_SCORE), 10, ); @@ -178,25 +172,25 @@ class SorobanService { private isMissingScoreError(message: string): boolean { const lower = message.toLowerCase(); return ( - lower.includes("not found") || - lower.includes("unknown address") || - lower.includes("missing value") || - lower.includes("does not exist") || - lower.includes("contract, #") || - lower.includes("hosterror") + lower.includes('not found') || + lower.includes('unknown address') || + lower.includes('missing value') || + lower.includes('does not exist') || + lower.includes('contract, #') || + lower.includes('hosterror') ); } private isTransientRpcError(message: string): boolean { const lower = message.toLowerCase(); return ( - lower.includes("timeout") || - lower.includes("temporar") || - lower.includes("connection") || - lower.includes("network") || - lower.includes("unavailable") || - lower.includes("503") || - lower.includes("502") + lower.includes('timeout') || + lower.includes('temporar') || + lower.includes('connection') || + lower.includes('network') || + lower.includes('unavailable') || + lower.includes('503') || + lower.includes('502') ); } @@ -215,9 +209,9 @@ class SorobanService { const account = await server.getAccount(borrowerPublicKey); const borrowerScVal = nativeToScVal(Address.fromString(borrowerPublicKey), { - type: "address", + type: 'address', }); - const amountScVal = nativeToScVal(BigInt(amount), { type: "i128" }); + const amountScVal = nativeToScVal(BigInt(amount), { type: 'i128' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -226,7 +220,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "request_loan", + function: 'request_loan', args: [borrowerScVal, amountScVal], }), ) @@ -236,7 +230,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built request_loan transaction", { + logger.withContext().info('Built request_loan transaction', { borrower: borrowerPublicKey, amount, }); @@ -260,10 +254,10 @@ class SorobanService { const account = await server.getAccount(borrowerPublicKey); const borrowerScVal = nativeToScVal(Address.fromString(borrowerPublicKey), { - type: "address", + type: 'address', }); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); - const amountScVal = nativeToScVal(BigInt(amount), { type: "i128" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); + const amountScVal = nativeToScVal(BigInt(amount), { type: 'i128' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -272,7 +266,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "repay", + function: 'repay', args: [borrowerScVal, loanIdScVal, amountScVal], }), ) @@ -282,7 +276,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built repay transaction", { + logger.withContext().info('Built repay transaction', { borrower: borrowerPublicKey, loanId, amount, @@ -308,12 +302,12 @@ class SorobanService { const account = await server.getAccount(providerPublicKey); const providerScVal = nativeToScVal(Address.fromString(providerPublicKey), { - type: "address", + type: 'address', }); const tokenScVal = nativeToScVal(Address.fromString(tokenAddress), { - type: "address", + type: 'address', }); - const amountScVal = nativeToScVal(BigInt(amount), { type: "i128" }); + const amountScVal = nativeToScVal(BigInt(amount), { type: 'i128' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -322,7 +316,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "deposit", + function: 'deposit', args: [providerScVal, tokenScVal, amountScVal], }), ) @@ -332,7 +326,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built deposit transaction", { + logger.withContext().info('Built deposit transaction', { provider: providerPublicKey, token: tokenAddress, amount, @@ -358,12 +352,12 @@ class SorobanService { const account = await server.getAccount(providerPublicKey); const providerScVal = nativeToScVal(Address.fromString(providerPublicKey), { - type: "address", + type: 'address', }); const tokenScVal = nativeToScVal(Address.fromString(tokenAddress), { - type: "address", + type: 'address', }); - const sharesScVal = nativeToScVal(BigInt(shares), { type: "i128" }); + const sharesScVal = nativeToScVal(BigInt(shares), { type: 'i128' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -372,7 +366,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "withdraw", + function: 'withdraw', args: [providerScVal, tokenScVal, sharesScVal], }), ) @@ -382,7 +376,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built withdraw transaction", { + logger.withContext().info('Built withdraw transaction', { provider: providerPublicKey, token: tokenAddress, shares, @@ -408,12 +402,12 @@ class SorobanService { const account = await server.getAccount(providerPublicKey); const providerScVal = nativeToScVal(Address.fromString(providerPublicKey), { - type: "address", + type: 'address', }); const tokenScVal = nativeToScVal(Address.fromString(tokenAddress), { - type: "address", + type: 'address', }); - const sharesScVal = nativeToScVal(BigInt(shares), { type: "i128" }); + const sharesScVal = nativeToScVal(BigInt(shares), { type: 'i128' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -422,7 +416,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "emergency_withdraw", + function: 'emergency_withdraw', args: [providerScVal, tokenScVal, sharesScVal], }), ) @@ -432,7 +426,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.info("Built emergency_withdraw transaction", { + logger.info('Built emergency_withdraw transaction', { provider: providerPublicKey, token: tokenAddress, shares, @@ -456,7 +450,7 @@ class SorobanService { const account = await server.getAccount(adminPublicKey); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -465,7 +459,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "approve_loan", + function: 'approve_loan', args: [loanIdScVal], }), ) @@ -475,7 +469,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built approve_loan transaction", { + logger.withContext().info('Built approve_loan transaction', { admin: adminPublicKey, loanId, }); @@ -498,8 +492,8 @@ class SorobanService { const account = await server.getAccount(borrowerPublicKey); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); - const amountScVal = nativeToScVal(BigInt(amount), { type: "i128" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); + const amountScVal = nativeToScVal(BigInt(amount), { type: 'i128' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -508,7 +502,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "deposit_collateral", + function: 'deposit_collateral', args: [loanIdScVal, amountScVal], }), ) @@ -518,7 +512,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built deposit_collateral transaction", { + logger.withContext().info('Built deposit_collateral transaction', { borrower: borrowerPublicKey, loanId, amount, @@ -541,7 +535,7 @@ class SorobanService { const account = await server.getAccount(borrowerPublicKey); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -550,7 +544,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "release_collateral", + function: 'release_collateral', args: [loanIdScVal], }), ) @@ -560,7 +554,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built release_collateral transaction", { + logger.withContext().info('Built release_collateral transaction', { borrower: borrowerPublicKey, loanId, }); @@ -584,9 +578,9 @@ class SorobanService { const account = await server.getAccount(borrowerPublicKey); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); - const amountScVal = nativeToScVal(BigInt(newAmount), { type: "i128" }); - const termScVal = nativeToScVal(newTerm, { type: "u32" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); + const amountScVal = nativeToScVal(BigInt(newAmount), { type: 'i128' }); + const termScVal = nativeToScVal(newTerm, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -595,7 +589,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "refinance_loan", + function: 'refinance_loan', args: [loanIdScVal, amountScVal, termScVal], }), ) @@ -605,7 +599,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built refinance_loan transaction", { + logger.withContext().info('Built refinance_loan transaction', { borrower: borrowerPublicKey, loanId, newAmount, @@ -631,10 +625,10 @@ class SorobanService { const account = await server.getAccount(borrowerPublicKey); const borrowerScVal = nativeToScVal(Address.fromString(borrowerPublicKey), { - type: "address", + type: 'address', }); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); - const extraLedgersScVal = nativeToScVal(extraLedgers, { type: "u32" }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); + const extraLedgersScVal = nativeToScVal(extraLedgers, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -643,7 +637,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "extend_loan", + function: 'extend_loan', args: [borrowerScVal, loanIdScVal, extraLedgersScVal], }), ) @@ -653,7 +647,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built extend_loan transaction", { + logger.withContext().info('Built extend_loan transaction', { borrower: borrowerPublicKey, loanId, extraLedgers, @@ -676,13 +670,10 @@ class SorobanService { const account = await server.getAccount(liquidatorPublicKey); - const liquidatorScVal = nativeToScVal( - Address.fromString(liquidatorPublicKey), - { - type: "address", - }, - ); - const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); + const liquidatorScVal = nativeToScVal(Address.fromString(liquidatorPublicKey), { + type: 'address', + }); + const loanIdScVal = nativeToScVal(loanId, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -691,7 +682,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "liquidate", + function: 'liquidate', args: [liquidatorScVal, loanIdScVal], }), ) @@ -701,7 +692,7 @@ class SorobanService { const prepared = await server.prepareTransaction(tx); const unsignedTxXdr = prepared.toXDR(); - logger.withContext().info("Built liquidate transaction", { + logger.withContext().info('Built liquidate transaction', { liquidator: liquidatorPublicKey, loanId, }); @@ -718,13 +709,10 @@ class SorobanService { */ async validateConfig(): Promise { const contractChecks: Array<[string, string]> = [ - ["LOAN_MANAGER_CONTRACT_ID", process.env.LOAN_MANAGER_CONTRACT_ID ?? ""], - ["LENDING_POOL_CONTRACT_ID", process.env.LENDING_POOL_CONTRACT_ID ?? ""], - [ - "REMITTANCE_NFT_CONTRACT_ID", - process.env.REMITTANCE_NFT_CONTRACT_ID ?? "", - ], - ["POOL_TOKEN_ADDRESS", process.env.POOL_TOKEN_ADDRESS ?? ""], + ['LOAN_MANAGER_CONTRACT_ID', process.env.LOAN_MANAGER_CONTRACT_ID ?? ''], + ['LENDING_POOL_CONTRACT_ID', process.env.LENDING_POOL_CONTRACT_ID ?? ''], + ['REMITTANCE_NFT_CONTRACT_ID', process.env.REMITTANCE_NFT_CONTRACT_ID ?? ''], + ['POOL_TOKEN_ADDRESS', process.env.POOL_TOKEN_ADDRESS ?? ''], ]; for (const [name, value] of contractChecks) { @@ -732,9 +720,7 @@ class SorobanService { throw AppError.internal(`${name} is not configured`); } if (!StrKey.isValidContract(value)) { - throw AppError.internal( - `${name} is not a valid Stellar contract address: "${value}"`, - ); + throw AppError.internal(`${name} is not a valid Stellar contract address: "${value}"`); } } @@ -743,9 +729,7 @@ class SorobanService { rpcUrl = getStellarRpcUrl(); } catch (err) { throw AppError.internal( - err instanceof Error - ? err.message - : "Invalid Stellar RPC configuration", + err instanceof Error ? err.message : 'Invalid Stellar RPC configuration', ); } @@ -757,7 +741,7 @@ class SorobanService { ); } - logger.withContext().info("Soroban configuration validated", { + logger.withContext().info('Soroban configuration validated', { loanManagerContractId: process.env.LOAN_MANAGER_CONTRACT_ID, lendingPoolContractId: process.env.LENDING_POOL_CONTRACT_ID, rpcUrl, @@ -775,19 +759,16 @@ class SorobanService { }> { const server = this.getRpcServer(); - const tx = TransactionBuilder.fromXDR( - signedTxXdr, - this.getNetworkPassphrase(), - ); + const tx = TransactionBuilder.fromXDR(signedTxXdr, this.getNetworkPassphrase()); const sendResult = await server.sendTransaction(tx); const txHash = sendResult.hash; if (!txHash) { - throw AppError.internal("Transaction submission returned no hash"); + throw AppError.internal('Transaction submission returned no hash'); } - logger.withContext().info("Transaction submitted", { + logger.withContext().info('Transaction submitted', { txHash, status: sendResult.status, }); @@ -799,8 +780,8 @@ class SorobanService { }); const resultXdr = - polled.status === "SUCCESS" && polled.resultXdr - ? polled.resultXdr.toXDR("base64") + polled.status === 'SUCCESS' && polled.resultXdr + ? polled.resultXdr.toXDR('base64') : undefined; return { @@ -822,7 +803,7 @@ class SorobanService { const account = await server.getAccount(source.publicKey()); const userScVal = nativeToScVal(Address.fromString(userPublicKey), { - type: "address", + type: 'address', }); const tx = new TransactionBuilder(account, { @@ -832,7 +813,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "get_score", + function: 'get_score', args: [userScVal], }), ) @@ -840,54 +821,40 @@ class SorobanService { .build(); const defaultScore = this.getDefaultCreditScore(); - let simulation: Awaited< - ReturnType - > | null = null; - - for ( - let attempt = 1; - attempt <= SorobanService.SCORE_SIMULATION_RETRY_ATTEMPTS; - attempt += 1 - ) { + let simulation: Awaited> | null = null; + + for (let attempt = 1; attempt <= SorobanService.SCORE_SIMULATION_RETRY_ATTEMPTS; attempt += 1) { simulation = await server.simulateTransaction(tx); - if (!("error" in simulation)) { + if (!('error' in simulation)) { break; } - const message = String(simulation.error ?? ""); + const message = String(simulation.error ?? ''); const isRetryable = this.isTransientRpcError(message); - const hasMoreAttempts = - attempt < SorobanService.SCORE_SIMULATION_RETRY_ATTEMPTS; + const hasMoreAttempts = attempt < SorobanService.SCORE_SIMULATION_RETRY_ATTEMPTS; if (!isRetryable || !hasMoreAttempts) { break; } - logger - .withContext() - .warn("Retrying get_score simulation after transient RPC failure", { - borrower: userPublicKey, - attempt, - error: message, - }); + logger.withContext().warn('Retrying get_score simulation after transient RPC failure', { + borrower: userPublicKey, + attempt, + error: message, + }); } if (!simulation) { - logger - .withContext() - .warn("Falling back to default credit score: empty simulation", { - borrower: userPublicKey, - defaultScore, - }); + logger.withContext().warn('Falling back to default credit score: empty simulation', { + borrower: userPublicKey, + defaultScore, + }); return defaultScore; } - if ("error" in simulation) { - const message = String(simulation.error ?? ""); - if ( - this.isMissingScoreError(message) || - this.isTransientRpcError(message) - ) { - logger.withContext().warn("Falling back to default credit score", { + if ('error' in simulation) { + const message = String(simulation.error ?? ''); + if (this.isMissingScoreError(message) || this.isTransientRpcError(message)) { + logger.withContext().warn('Falling back to default credit score', { borrower: userPublicKey, defaultScore, reason: message, @@ -895,32 +862,26 @@ class SorobanService { return defaultScore; } - throw AppError.internal( - `Failed to simulate get_score for ${userPublicKey}: ${message}`, - ); + throw AppError.internal(`Failed to simulate get_score for ${userPublicKey}: ${message}`); } const retval = simulation.result?.retval; if (!retval) { - logger - .withContext() - .warn("Falling back to default credit score: no score returned", { - borrower: userPublicKey, - defaultScore, - }); + logger.withContext().warn('Falling back to default credit score: no score returned', { + borrower: userPublicKey, + defaultScore, + }); return defaultScore; } const nativeScore = scValToNative(retval); const score = Number(nativeScore); if (!Number.isFinite(score)) { - logger - .withContext() - .warn("Falling back to default credit score: invalid score value", { - borrower: userPublicKey, - defaultScore, - nativeScore, - }); + logger.withContext().warn('Falling back to default credit score: invalid score value', { + borrower: userPublicKey, + defaultScore, + nativeScore, + }); return defaultScore; } @@ -946,10 +907,10 @@ class SorobanService { const account = await server.getAccount(source.publicKey()); const userScVal = nativeToScVal(Address.fromString(userPublicKey), { - type: "address", + type: 'address', }); - const offsetScVal = nativeToScVal(0, { type: "u32" }); - const limitScVal = nativeToScVal(50, { type: "u32" }); + const offsetScVal = nativeToScVal(0, { type: 'u32' }); + const limitScVal = nativeToScVal(50, { type: 'u32' }); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -958,7 +919,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: contractId, - function: "get_score_history", + function: 'get_score_history', args: [userScVal, offsetScVal, limitScVal], }), ) @@ -966,9 +927,9 @@ class SorobanService { .build(); const simulation = await server.simulateTransaction(tx); - if ("error" in simulation) { + if ('error' in simulation) { throw AppError.internal( - `Failed to simulate get_score_history for ${userPublicKey}: ${String(simulation.error ?? "")}`, + `Failed to simulate get_score_history for ${userPublicKey}: ${String(simulation.error ?? '')}`, ); } @@ -1002,22 +963,19 @@ class SorobanService { } private stringifyBytes(value: unknown): string { - if (typeof value === "string") return value; + if (typeof value === 'string') return value; if (value instanceof Uint8Array) { return Array.from(value) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); } - if ( - Array.isArray(value) && - value.every((item) => typeof item === "number") - ) { - return value.map((byte) => byte.toString(16).padStart(2, "0")).join(""); + if (Array.isArray(value) && value.every((item) => typeof item === 'number')) { + return value.map((byte) => byte.toString(16).padStart(2, '0')).join(''); } - if (value && typeof value === "object" && "toString" in value) { + if (value && typeof value === 'object' && 'toString' in value) { return String(value); } - return ""; + return ''; } private async simulateRemittanceNftRead( @@ -1032,7 +990,7 @@ class SorobanService { const account = await server.getAccount(source.publicKey()); const userScVal = nativeToScVal(Address.fromString(userPublicKey), { - type: "address", + type: 'address', }); const tx = new TransactionBuilder(account, { @@ -1050,15 +1008,13 @@ class SorobanService { .build(); const simulation = await server.simulateTransaction(tx); - if ("error" in simulation) { + if ('error' in simulation) { throw AppError.internal( - `Failed to simulate ${functionName} for ${userPublicKey}: ${String(simulation.error ?? "")}`, + `Failed to simulate ${functionName} for ${userPublicKey}: ${String(simulation.error ?? '')}`, ); } - return simulation.result?.retval - ? scValToNative(simulation.result.retval) - : null; + return simulation.result?.retval ? scValToNative(simulation.result.retval) : null; } async getRemittanceNftMetadata(userPublicKey: string): Promise<{ @@ -1070,7 +1026,7 @@ class SorobanService { lastUpdateLedger: number; } | null> { const nativeMetadata = (await this.simulateRemittanceNftRead( - "get_metadata", + 'get_metadata', userPublicKey, )) as Record | null; @@ -1079,11 +1035,8 @@ class SorobanService { } const [defaultCountNative, cooldownNative, history] = await Promise.all([ - this.simulateRemittanceNftRead("get_default_count", userPublicKey), - this.simulateRemittanceNftRead( - "get_transfer_cooldown_remaining", - userPublicKey, - ), + this.simulateRemittanceNftRead('get_default_count', userPublicKey), + this.simulateRemittanceNftRead('get_transfer_cooldown_remaining', userPublicKey), this.getOnChainScoreHistory(userPublicKey).catch(() => []), ]); @@ -1092,7 +1045,7 @@ class SorobanService { return { score: Number(nativeMetadata.score ?? 0), historyHash: this.stringifyBytes(nativeMetadata.history_hash), - metadataUri: String(nativeMetadata.metadata_uri ?? ""), + metadataUri: String(nativeMetadata.metadata_uri ?? ''), defaultCount: Number(defaultCountNative ?? 0), transferCooldownRemaining: Number(cooldownNative ?? 0), lastUpdateLedger: Number(latestHistoryEntry?.timestamp ?? 0), @@ -1110,8 +1063,8 @@ class SorobanService { }> { try { const server = this.getRpcServer(); - const timeoutPromise = new Promise<{ connected: boolean; error: string }>( - (_, reject) => setTimeout(() => reject(new Error("RPC timeout")), 5000), + const timeoutPromise = new Promise<{ connected: boolean; error: string }>((_, reject) => + setTimeout(() => reject(new Error('RPC timeout')), 5000), ); const ledgerPromise = server.getLatestLedger().then((res) => ({ @@ -1119,10 +1072,7 @@ class SorobanService { latestLedger: res.sequence, })); - return await Promise.race([ - ledgerPromise, - timeoutPromise as Promise, - ]); + return await Promise.race([ledgerPromise, timeoutPromise as Promise]); } catch (error) { return { connected: false, @@ -1144,7 +1094,7 @@ class SorobanService { const account = await server.getAccount(source.publicKey()); const tokenScVal = nativeToScVal(Address.fromString(token), { - type: "address", + type: 'address', }); const tx = new TransactionBuilder(account, { @@ -1154,7 +1104,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: poolId, - function: "get_share_price", + function: 'get_share_price', args: [tokenScVal], }), ) @@ -1162,21 +1112,19 @@ class SorobanService { .build(); const simulation = await server.simulateTransaction(tx); - if ("error" in simulation) { - throw AppError.internal( - `Failed to simulate get_share_price: ${simulation.error}`, - ); + if ('error' in simulation) { + throw AppError.internal(`Failed to simulate get_share_price: ${simulation.error}`); } const retval = simulation.result?.retval; if (!retval) { - throw AppError.internal("No share price returned by lending pool"); + throw AppError.internal('No share price returned by lending pool'); } const nativePrice = scValToNative(retval); const price = Number(nativePrice); if (!Number.isFinite(price)) { - throw AppError.internal("Invalid on-chain share price returned"); + throw AppError.internal('Invalid on-chain share price returned'); } return price; @@ -1195,7 +1143,7 @@ class SorobanService { const account = await server.getAccount(source.publicKey()); const poolScVal = nativeToScVal(Address.fromString(poolId), { - type: "address", + type: 'address', }); const tx = new TransactionBuilder(account, { @@ -1205,7 +1153,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: tokenAddress, - function: "balance", + function: 'balance', args: [poolScVal], }), ) @@ -1213,21 +1161,19 @@ class SorobanService { .build(); const simulation = await server.simulateTransaction(tx); - if ("error" in simulation) { - throw AppError.internal( - `Failed to simulate pool balance: ${simulation.error}`, - ); + if ('error' in simulation) { + throw AppError.internal(`Failed to simulate pool balance: ${simulation.error}`); } const retval = simulation.result?.retval; if (!retval) { - throw AppError.internal("No balance returned by pool token"); + throw AppError.internal('No balance returned by pool token'); } const nativeBalance = scValToNative(retval); const balance = Number(nativeBalance); if (!Number.isFinite(balance)) { - throw AppError.internal("Invalid on-chain balance returned"); + throw AppError.internal('Invalid on-chain balance returned'); } return balance; @@ -1247,7 +1193,7 @@ class SorobanService { .addOperation( Operation.invokeContractFunction({ contract: poolId, - function: "get_withdrawal_cooldown", + function: 'get_withdrawal_cooldown', args: [], }), ) @@ -1255,23 +1201,19 @@ class SorobanService { .build(); const simulation = await server.simulateTransaction(tx); - if ("error" in simulation) { - throw AppError.internal( - `Failed to simulate get_withdrawal_cooldown: ${simulation.error}`, - ); + if ('error' in simulation) { + throw AppError.internal(`Failed to simulate get_withdrawal_cooldown: ${simulation.error}`); } const retval = simulation.result?.retval; if (!retval) { - throw AppError.internal( - "No withdrawal cooldown returned by lending pool", - ); + throw AppError.internal('No withdrawal cooldown returned by lending pool'); } const nativeCooldown = scValToNative(retval); const cooldown = Number(nativeCooldown); if (!Number.isFinite(cooldown)) { - throw AppError.internal("Invalid withdrawal cooldown returned"); + throw AppError.internal('Invalid withdrawal cooldown returned'); } return cooldown; @@ -1288,18 +1230,9 @@ class SorobanService { defaultPenalty: number; latePenalty: number; } { - const repaymentDelta = Number.parseInt( - process.env.SCORE_DELTA_REPAY ?? "15", - 10, - ); - const defaultPenalty = Number.parseInt( - process.env.SCORE_DELTA_DEFAULT ?? "50", - 10, - ); - const latePenalty = Number.parseInt( - process.env.SCORE_DELTA_LATE ?? "5", - 10, - ); + const repaymentDelta = Number.parseInt(process.env.SCORE_DELTA_REPAY ?? '15', 10); + const defaultPenalty = Number.parseInt(process.env.SCORE_DELTA_DEFAULT ?? '50', 10); + const latePenalty = Number.parseInt(process.env.SCORE_DELTA_LATE ?? '5', 10); return { repaymentDelta, defaultPenalty, latePenalty }; } @@ -1311,18 +1244,18 @@ class SorobanService { validateScoreConfig(): void { const configs = [ { - name: "SCORE_DELTA_REPAY", - value: process.env.SCORE_DELTA_REPAY ?? "15", + name: 'SCORE_DELTA_REPAY', + value: process.env.SCORE_DELTA_REPAY ?? '15', mustBePositive: true, }, { - name: "SCORE_DELTA_DEFAULT", - value: process.env.SCORE_DELTA_DEFAULT ?? "50", + name: 'SCORE_DELTA_DEFAULT', + value: process.env.SCORE_DELTA_DEFAULT ?? '50', mustBePositive: true, }, { - name: "SCORE_DELTA_LATE", - value: process.env.SCORE_DELTA_LATE ?? "5", + name: 'SCORE_DELTA_LATE', + value: process.env.SCORE_DELTA_LATE ?? '5', mustBePositive: true, }, ]; @@ -1337,10 +1270,10 @@ class SorobanService { } } - logger.withContext().info("Score delta configuration validated", { - repaymentDelta: process.env.SCORE_DELTA_REPAY ?? "15", - defaultPenalty: process.env.SCORE_DELTA_DEFAULT ?? "50", - latePenalty: process.env.SCORE_DELTA_LATE ?? "5", + logger.withContext().info('Score delta configuration validated', { + repaymentDelta: process.env.SCORE_DELTA_REPAY ?? '15', + defaultPenalty: process.env.SCORE_DELTA_DEFAULT ?? '50', + latePenalty: process.env.SCORE_DELTA_LATE ?? '5', }); } } diff --git a/backend/src/services/webhookRetryProcessor.ts b/backend/src/services/webhookRetryProcessor.ts index 59730883..a2e5d398 100644 --- a/backend/src/services/webhookRetryProcessor.ts +++ b/backend/src/services/webhookRetryProcessor.ts @@ -1,7 +1,7 @@ -import logger from "../utils/logger.js"; -import { refreshWebhookRetryQueueDepth } from "../middleware/metrics.js"; -import { WebhookService } from "./webhookService.js"; -import { jobMetricsService } from "./jobMetricsService.js"; +import logger from '../utils/logger.js'; +import { refreshWebhookRetryQueueDepth } from '../middleware/metrics.js'; +import { WebhookService } from './webhookService.js'; +import { jobMetricsService } from './jobMetricsService.js'; let retryProcessorInterval: NodeJS.Timeout | null = null; @@ -13,16 +13,16 @@ let retryProcessorInterval: NodeJS.Timeout | null = null; */ export function startWebhookRetryProcessor(): void { if (retryProcessorInterval) { - logger.withContext().warn("Webhook retry processor already running"); + logger.withContext().warn('Webhook retry processor already running'); return; } - logger.withContext().info("Starting webhook retry processor"); + logger.withContext().info('Starting webhook retry processor'); // Run retry processor every 10 seconds retryProcessorInterval = setInterval(async () => { const startTime = Date.now(); - const jobName = "webhookRetryProcessor"; + const jobName = 'webhookRetryProcessor'; try { await refreshWebhookRetryQueueDepth(); @@ -33,14 +33,8 @@ export function startWebhookRetryProcessor(): void { jobMetricsService.recordSuccess(jobName, durationMs); } catch (error) { const durationMs = Date.now() - startTime; - jobMetricsService.recordFailure( - jobName, - error as Error | string, - durationMs, - ); - logger - .withContext() - .error("Error in webhook retry processor interval", { error }); + jobMetricsService.recordFailure(jobName, error as Error | string, durationMs); + logger.withContext().error('Error in webhook retry processor interval', { error }); } }, 10 * 1000); } @@ -50,7 +44,7 @@ export function startWebhookRetryProcessor(): void { */ export function stopWebhookRetryProcessor(): void { if (retryProcessorInterval) { - logger.withContext().info("Stopping webhook retry processor"); + logger.withContext().info('Stopping webhook retry processor'); clearInterval(retryProcessorInterval); retryProcessorInterval = null; } diff --git a/backend/src/services/webhookRetryScheduler.ts b/backend/src/services/webhookRetryScheduler.ts index 15e9a3cc..7fc39f36 100644 --- a/backend/src/services/webhookRetryScheduler.ts +++ b/backend/src/services/webhookRetryScheduler.ts @@ -1,6 +1,6 @@ -import { query } from "../db/connection.js"; -import logger from "../utils/logger.js"; -import { WebhookService, type WebhookEventType } from "./webhookService.js"; +import { query } from '../db/connection.js'; +import logger from '../utils/logger.js'; +import { WebhookService, type WebhookEventType } from './webhookService.js'; const BACKOFF = [60, 300, 1800]; // seconds @@ -13,11 +13,9 @@ async function markAsFailed(deliveryId: number) { last_error = $1, updated_at = NOW() WHERE id = $2`, - ["Permanently failed after max attempts reached", deliveryId], + ['Permanently failed after max attempts reached', deliveryId], ); - logger - .withContext() - .error(`Webhook delivery ${deliveryId} marked as permanently failed.`); + logger.withContext().error(`Webhook delivery ${deliveryId} marked as permanently failed.`); } function shouldRetry(delivery: { updated_at: string }, delay: number): boolean { @@ -39,9 +37,7 @@ async function sendWebhookAgain(delivery: { }) { logger .withContext() - .info( - `Retrying webhook delivery ${delivery.id} (attempt ${delivery.attempt_count + 1})`, - ); + .info(`Retrying webhook delivery ${delivery.id} (attempt ${delivery.attempt_count + 1})`); await WebhookService.retryWebhookDelivery( delivery.id, @@ -80,23 +76,23 @@ export async function retryFailedWebhooks() { } } } catch (error) { - logger.withContext().error("Error in webhook retry scheduler", { error }); + logger.withContext().error('Error in webhook retry scheduler', { error }); } } export function startWebhookRetryScheduler() { if (schedulerInterval) { - logger.withContext().warn("Webhook retry scheduler already running"); + logger.withContext().warn('Webhook retry scheduler already running'); return; } - logger.withContext().info("Starting webhook retry scheduler (60s interval)"); + logger.withContext().info('Starting webhook retry scheduler (60s interval)'); schedulerInterval = setInterval(retryFailedWebhooks, 60000); } export function stopWebhookRetryScheduler() { if (schedulerInterval) { - logger.withContext().info("Stopping webhook retry scheduler"); + logger.withContext().info('Stopping webhook retry scheduler'); clearInterval(schedulerInterval); schedulerInterval = null; } diff --git a/backend/src/services/webhookService.ts b/backend/src/services/webhookService.ts index cc2f8854..357c6c54 100644 --- a/backend/src/services/webhookService.ts +++ b/backend/src/services/webhookService.ts @@ -1,73 +1,73 @@ -import crypto from "node:crypto"; -import { query } from "../db/connection.js"; -import logger from "../utils/logger.js"; +import crypto from 'node:crypto'; +import { query } from '../db/connection.js'; +import logger from '../utils/logger.js'; export const SUPPORTED_WEBHOOK_EVENT_TYPES = [ - "LoanRequested", - "LoanApproved", - "LoanRepaid", - "LoanDefaulted", - "CollateralLiquidated", - "CollateralReturned", - "CollateralDeposited", - "CollateralReleased", - "ColDep", - "ColRel", - "LateFeeCharged", - "LoanExtended", - "LoanCancelled", - "LoanRejected", - "LoanRefinanced", - "InterestRateUpdated", - "DefaultTermUpdated", - "TermLimitsUpdated", - "LateFeeRateUpdated", - "GracePeriodUpdated", - "DefaultWindowUpdated", - "MaxLoanAmountUpdated", - "MinRepaymentUpdated", - "MaxLoansPerBorrower", - "MinRateBpsUpdated", - "MaxRateBpsUpdated", - "RateOracleUpdated", - "Deposit", - "Withdraw", - "YieldDistributed", - "EmergencyWithdraw", - "DepositCapUpdated", - "WithdrawalCooldownUpdated", - "NFTMinted", - "ScoreUpdated", - "NFTSeized", - "NFTBurned", - "ProposalCreated", - "ProposalApproved", - "ProposalFinalized", - "ProposalCancelled", - "LoanApprv", - "LoanLiquidated", + 'LoanRequested', + 'LoanApproved', + 'LoanRepaid', + 'LoanDefaulted', + 'CollateralLiquidated', + 'CollateralReturned', + 'CollateralDeposited', + 'CollateralReleased', + 'ColDep', + 'ColRel', + 'LateFeeCharged', + 'LoanExtended', + 'LoanCancelled', + 'LoanRejected', + 'LoanRefinanced', + 'InterestRateUpdated', + 'DefaultTermUpdated', + 'TermLimitsUpdated', + 'LateFeeRateUpdated', + 'GracePeriodUpdated', + 'DefaultWindowUpdated', + 'MaxLoanAmountUpdated', + 'MinRepaymentUpdated', + 'MaxLoansPerBorrower', + 'MinRateBpsUpdated', + 'MaxRateBpsUpdated', + 'RateOracleUpdated', + 'Deposit', + 'Withdraw', + 'YieldDistributed', + 'EmergencyWithdraw', + 'DepositCapUpdated', + 'WithdrawalCooldownUpdated', + 'NFTMinted', + 'ScoreUpdated', + 'NFTSeized', + 'NFTBurned', + 'ProposalCreated', + 'ProposalApproved', + 'ProposalFinalized', + 'ProposalCancelled', + 'LoanApprv', + 'LoanLiquidated', // Legacy aliases kept to preserve compatibility for existing subscribers. - "Mint", - "ScoreUpd", - "ScoreDecr", - "Seized", - "NftBurned", - "AdmRemint", - "HashUpd", - "GovProp", - "GovAppr", - "GovFin", - "Transfer", - "MntAuth", - "MntRev", - "Paused", - "Unpaused", - "MinScoreUpdated", - "PoolPaused", - "PoolUnpaused", - "GovCncl", - "GovEmerg", - "GovExp", + 'Mint', + 'ScoreUpd', + 'ScoreDecr', + 'Seized', + 'NftBurned', + 'AdmRemint', + 'HashUpd', + 'GovProp', + 'GovAppr', + 'GovFin', + 'Transfer', + 'MntAuth', + 'MntRev', + 'Paused', + 'Unpaused', + 'MinScoreUpdated', + 'PoolPaused', + 'PoolUnpaused', + 'GovCncl', + 'GovEmerg', + 'GovExp', ] as const; export type WebhookEventType = (typeof SUPPORTED_WEBHOOK_EVENT_TYPES)[number]; @@ -123,7 +123,7 @@ interface PreparedWebhookPayload { } function parsePositiveInt(value: string | undefined, fallback: number): number { - const parsed = Number.parseInt(value ?? "", 10); + const parsed = Number.parseInt(value ?? '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } @@ -142,18 +142,12 @@ function summarizeOversizedPayload( ): Record { const summary: Record = { truncated: true, - reason: "payload_too_large", + reason: 'payload_too_large', originalPayloadBytes, maxPayloadBytes, }; - const passthroughKeys = [ - "eventId", - "eventType", - "loanId", - "address", - "ledger", - ] as const; + const passthroughKeys = ['eventId', 'eventType', 'loanId', 'address', 'ledger'] as const; for (const key of passthroughKeys) { const value = payload[key]; @@ -176,46 +170,34 @@ function summarizeOversizedPayloadMinimal( ): Record { const summary: Record = { truncated: true, - reason: "payload_too_large", + reason: 'payload_too_large', originalPayloadBytes, maxPayloadBytes, }; - if (typeof payload.eventId === "string") { + if (typeof payload.eventId === 'string') { summary.eventId = payload.eventId; } - if (typeof payload.eventType === "string") { + if (typeof payload.eventType === 'string') { summary.eventType = payload.eventType; } return summary; } -function prepareWebhookPayload( - payload: Record, -): PreparedWebhookPayload { +function prepareWebhookPayload(payload: Record): PreparedWebhookPayload { const body = JSON.stringify(payload); const payloadBytes = Buffer.byteLength(body); const maxPayloadBytes = getWebhookMaxPayloadBytes(); - const eventId = - typeof payload.eventId === "string" ? payload.eventId : undefined; - const eventType = - typeof payload.eventType === "string" ? payload.eventType : undefined; + const eventId = typeof payload.eventId === 'string' ? payload.eventId : undefined; + const eventType = typeof payload.eventType === 'string' ? payload.eventType : undefined; if (payloadBytes > maxPayloadBytes) { - let summarizedPayload = summarizeOversizedPayload( - payload, - payloadBytes, - maxPayloadBytes, - ); + let summarizedPayload = summarizeOversizedPayload(payload, payloadBytes, maxPayloadBytes); let summarizedBody = JSON.stringify(summarizedPayload); if (Buffer.byteLength(summarizedBody) > maxPayloadBytes) { - summarizedPayload = summarizeOversizedPayloadMinimal( - payload, - payloadBytes, - maxPayloadBytes, - ); + summarizedPayload = summarizeOversizedPayloadMinimal(payload, payloadBytes, maxPayloadBytes); summarizedBody = JSON.stringify(summarizedPayload); } @@ -225,14 +207,12 @@ function prepareWebhookPayload( ); } - logger - .withContext() - .warn("Webhook payload exceeds size limit, sending summary payload", { - eventId, - eventType, - payloadBytes, - maxPayloadBytes, - }); + logger.withContext().warn('Webhook payload exceeds size limit, sending summary payload', { + eventId, + eventType, + payloadBytes, + maxPayloadBytes, + }); return { body: summarizedBody, @@ -241,7 +221,7 @@ function prepareWebhookPayload( } if (payloadBytes >= Math.floor(maxPayloadBytes * 0.9)) { - logger.withContext().warn("Webhook payload is near size limit", { + logger.withContext().warn('Webhook payload is near size limit', { eventId, eventType, payloadBytes, @@ -267,19 +247,19 @@ async function postWebhook( try { return await fetch(callbackUrl, { - method: "POST", + method: 'POST', headers: { - "content-type": "application/json", + 'content-type': 'application/json', // X-RemitLend-Signature uses the GitHub/Stripe-style "sha256=" // format so subscribers can verify payload integrity (see // docs/wiki/webhook-signatures.md for the verification recipe). - ...(signature && { "x-remitlend-signature": `sha256=${signature}` }), + ...(signature && { 'x-remitlend-signature': `sha256=${signature}` }), }, body, signal: controller.signal, }); } catch (error) { - if (error instanceof Error && error.name === "AbortError") { + if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Webhook request timed out after ${timeoutMs}ms`); } throw error; @@ -291,25 +271,19 @@ async function postWebhook( // Retry configuration for webhook delivery. // This yields retry attempts at ~5m, ~15m, and ~45m after a failed delivery, // for a total retry window a little over one hour after the initial attempt. -const RETRY_DELAYS_MS = [ - 5 * 60 * 1000, - 15 * 60 * 1000, - 45 * 60 * 1000, -] as const; +const RETRY_DELAYS_MS = [5 * 60 * 1000, 15 * 60 * 1000, 45 * 60 * 1000] as const; const MAX_RETRY_ATTEMPTS = RETRY_DELAYS_MS.length + 1; export const getRetryDelayMs = (attemptNumber: number): number => { const delayIndex = Math.min(attemptNumber - 1, RETRY_DELAYS_MS.length - 1); - return ( - RETRY_DELAYS_MS[delayIndex] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]! - ); + return RETRY_DELAYS_MS[delayIndex] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]!; }; export class WebhookService { // Retry processor that polls for pending retries static async processRetries(): Promise { - logger.withContext().info("Starting webhook retry processor"); + logger.withContext().info('Starting webhook retry processor'); try { const now = new Date(); @@ -328,13 +302,11 @@ export class WebhookService { ); if (result.rows.length === 0) { - logger.debug("No pending webhook retries"); + logger.debug('No pending webhook retries'); return; } - logger - .withContext() - .info(`Processing ${result.rows.length} pending webhook retries`); + logger.withContext().info(`Processing ${result.rows.length} pending webhook retries`); for (const row of result.rows) { const delivery = row as unknown as { @@ -365,7 +337,7 @@ export class WebhookService { ); } } catch (error) { - logger.withContext().error("Error in webhook retry processor", { error }); + logger.withContext().error('Error in webhook retry processor', { error }); } } @@ -383,7 +355,7 @@ export class WebhookService { const body = preparedPayload.body; const signature = secret - ? crypto.createHmac("sha256", secret).update(body).digest("hex") + ? crypto.createHmac('sha256', secret).update(body).digest('hex') : undefined; let response: Response | null = null; @@ -405,16 +377,10 @@ export class WebhookService { next_retry_at = NULL, updated_at = $4 WHERE id = $5`, - [ - newAttemptCount, - response.status, - new Date(), - new Date(), - deliveryId, - ], + [newAttemptCount, response.status, new Date(), new Date(), deliveryId], ); - logger.withContext().info("Webhook delivery succeeded after retry", { + logger.withContext().info('Webhook delivery succeeded after retry', { deliveryId, subscriptionId, eventId, @@ -436,38 +402,27 @@ export class WebhookService { next_retry_at = $4, updated_at = $5 WHERE id = $6`, - [ - newAttemptCount, - response.status, - errorMsg, - nextRetryTime, - new Date(), - deliveryId, - ], + [newAttemptCount, response.status, errorMsg, nextRetryTime, new Date(), deliveryId], ); if (nextRetryTime) { - logger - .withContext() - .warn("Webhook delivery failed, scheduled retry", { - deliveryId, - subscriptionId, - eventId, - attemptCount: newAttemptCount, - statusCode: response.status, - nextRetryAt: nextRetryTime, - }); + logger.withContext().warn('Webhook delivery failed, scheduled retry', { + deliveryId, + subscriptionId, + eventId, + attemptCount: newAttemptCount, + statusCode: response.status, + nextRetryAt: nextRetryTime, + }); } else { - logger - .withContext() - .error("Webhook delivery permanently failed after max retries", { - deliveryId, - subscriptionId, - eventId, - attemptCount: newAttemptCount, - statusCode: response.status, - payload: body, - }); + logger.withContext().error('Webhook delivery permanently failed after max retries', { + deliveryId, + subscriptionId, + eventId, + attemptCount: newAttemptCount, + statusCode: response.status, + payload: body, + }); } } } catch (error) { @@ -477,8 +432,7 @@ export class WebhookService { ? new Date(Date.now() + getRetryDelayMs(newAttemptCount)) : null; - const errorMsg = - error instanceof Error ? error.message : "Unknown webhook error"; + const errorMsg = error instanceof Error ? error.message : 'Unknown webhook error'; await query( `UPDATE webhook_deliveries @@ -491,7 +445,7 @@ export class WebhookService { ); if (nextRetryTime) { - logger.withContext().warn("Webhook delivery error, scheduled retry", { + logger.withContext().warn('Webhook delivery error, scheduled retry', { deliveryId, subscriptionId, eventId, @@ -500,15 +454,13 @@ export class WebhookService { nextRetryAt: nextRetryTime, }); } else { - logger - .withContext() - .error("Webhook delivery permanently failed after max retries", { - deliveryId, - subscriptionId, - eventId, - attemptCount: newAttemptCount, - error, - }); + logger.withContext().error('Webhook delivery permanently failed after max retries', { + deliveryId, + subscriptionId, + eventId, + attemptCount: newAttemptCount, + error, + }); } } } @@ -516,18 +468,12 @@ export class WebhookService { return SUPPORTED_WEBHOOK_EVENT_TYPES.includes(type as WebhookEventType); } - async registerSubscription( - input: RegisterWebhookInput, - ): Promise { + async registerSubscription(input: RegisterWebhookInput): Promise { const result = await query( `INSERT INTO webhook_subscriptions (callback_url, event_types, secret, is_active) VALUES ($1, $2::jsonb, $3, true) RETURNING id, callback_url, event_types, secret, is_active, created_at, updated_at`, - [ - input.callbackUrl, - JSON.stringify(input.eventTypes), - input.secret ?? null, - ], + [input.callbackUrl, JSON.stringify(input.eventTypes), input.secret ?? null], ); return this.mapSubscriptionRow(result.rows[0] as Record); @@ -541,9 +487,7 @@ export class WebhookService { [], ); - return result.rows.map((row) => - this.mapSubscriptionRow(row as Record), - ); + return result.rows.map((row) => this.mapSubscriptionRow(row as Record)); } async deleteSubscription(id: number): Promise { @@ -570,13 +514,11 @@ export class WebhookService { [subscriptionId, limit], ); - return result.rows.map((row) => - this.mapDeliveryRow(row as Record), - ); + return result.rows.map((row) => this.mapDeliveryRow(row as Record)); } async dispatch(event: IndexedLoanEvent): Promise { - logger.withContext().info("Dispatching webhook event", { + logger.withContext().info('Dispatching webhook event', { eventId: event.eventId, eventType: event.eventType, loanId: event.loanId, @@ -584,9 +526,7 @@ export class WebhookService { }); try { - const preparedPayload = prepareWebhookPayload( - event as unknown as Record, - ); + const preparedPayload = prepareWebhookPayload(event as unknown as Record); const webhooksResult = await query( `SELECT id, callback_url, secret FROM webhook_subscriptions @@ -600,14 +540,13 @@ export class WebhookService { this.sendToWebhook( Number((hook as { id: number }).id), String((hook as { callback_url: string }).callback_url), - ((hook as { secret?: string | null }).secret ?? undefined) || - undefined, + ((hook as { secret?: string | null }).secret ?? undefined) || undefined, preparedPayload, ), ), ); } catch (error) { - logger.withContext().error("Error during webhook dispatch", { + logger.withContext().error('Error during webhook dispatch', { eventId: event.eventId, eventType: event.eventType, error, @@ -624,7 +563,7 @@ export class WebhookService { const body = payload.body; const signature = secret - ? crypto.createHmac("sha256", secret).update(body).digest("hex") + ? crypto.createHmac('sha256', secret).update(body).digest('hex') : undefined; try { @@ -681,7 +620,7 @@ export class WebhookService { ], ); - logger.withContext().warn("Webhook delivery failed, scheduled retry", { + logger.withContext().warn('Webhook delivery failed, scheduled retry', { subscriptionId, callbackUrl, eventId: payload.payload.eventId, @@ -707,13 +646,13 @@ export class WebhookService { subscriptionId, payload.payload.eventId, payload.payload.eventType, - error instanceof Error ? error.message : "Unknown webhook error", + error instanceof Error ? error.message : 'Unknown webhook error', body, nextRetryAt, ], ); - logger.withContext().error("Failed to send webhook, scheduled retry", { + logger.withContext().error('Failed to send webhook, scheduled retry', { subscriptionId, callbackUrl, eventId: payload.payload.eventId, @@ -723,13 +662,8 @@ export class WebhookService { } } - private mapSubscriptionRow( - row: Record, - ): WebhookSubscription { - const secret = - typeof row.secret === "string" && row.secret.length > 0 - ? row.secret - : undefined; + private mapSubscriptionRow(row: Record): WebhookSubscription { + const secret = typeof row.secret === 'string' && row.secret.length > 0 ? row.secret : undefined; return { id: Number(row.id), @@ -744,20 +678,16 @@ export class WebhookService { private mapDeliveryRow(row: Record): WebhookDelivery { const lastStatusCode = - typeof row.last_status_code === "number" + typeof row.last_status_code === 'number' ? row.last_status_code : row.last_status_code !== null && row.last_status_code !== undefined ? Number(row.last_status_code) : undefined; const lastError = - typeof row.last_error === "string" && row.last_error.length > 0 - ? row.last_error - : undefined; + typeof row.last_error === 'string' && row.last_error.length > 0 ? row.last_error : undefined; - const deliveredAt = row.delivered_at - ? new Date(String(row.delivered_at)) - : undefined; + const deliveredAt = row.delivered_at ? new Date(String(row.delivered_at)) : undefined; return { id: Number(row.id), diff --git a/backend/src/services/yieldHistoryService.ts b/backend/src/services/yieldHistoryService.ts index 2c87fff3..bcfdfccf 100644 --- a/backend/src/services/yieldHistoryService.ts +++ b/backend/src/services/yieldHistoryService.ts @@ -1,5 +1,5 @@ -import { scValToNative, xdr } from "@stellar/stellar-sdk"; -import { query } from "../db/connection.js"; +import { scValToNative, xdr } from '@stellar/stellar-sdk'; +import { query } from '../db/connection.js'; export const SHARE_PRICE_SCALE = 1_000_000; export const YIELD_HISTORY_ALLOWED_DAYS = [7, 30, 90] as const; @@ -39,16 +39,16 @@ function parseDepositWithdrawAmounts( } try { - const scVal = xdr.ScVal.fromXDR(valueXdr, "base64"); + const scVal = xdr.ScVal.fromXDR(valueXdr, 'base64'); const native = scValToNative(scVal); if (!Array.isArray(native) || native.length < 2) { return { assetAmount, shares: assetAmount }; } const sharesRaw = native[1]; const shares = - typeof sharesRaw === "bigint" + typeof sharesRaw === 'bigint' ? Number(sharesRaw) - : typeof sharesRaw === "number" + : typeof sharesRaw === 'number' ? sharesRaw : assetAmount; return { assetAmount, shares }; @@ -57,11 +57,7 @@ function parseDepositWithdrawAmounts( } } -function assetValueFromShares( - shares: number, - poolBalance: number, - totalShares: number, -): number { +function assetValueFromShares(shares: number, poolBalance: number, totalShares: number): number { if (shares <= 0 || totalShares <= 0) { return 0; } @@ -79,16 +75,16 @@ function applyPoolEvent( ); switch (event.event_type) { - case "Deposit": + case 'Deposit': state.poolBalance += assetAmount; state.totalShares += shares; break; - case "Withdraw": - case "EmergencyWithdraw": + case 'Withdraw': + case 'EmergencyWithdraw': state.poolBalance = Math.max(0, state.poolBalance - assetAmount); state.totalShares = Math.max(0, state.totalShares - shares); break; - case "YieldDistributed": + case 'YieldDistributed': state.poolBalance += assetAmount; break; default: @@ -107,16 +103,13 @@ function applyDepositorEvent( event.value, ); - if (event.event_type === "Deposit") { + if (event.event_type === 'Deposit') { state.shares += shares; state.costBasis += assetAmount; return; } - if ( - event.event_type === "Withdraw" || - event.event_type === "EmergencyWithdraw" - ) { + if (event.event_type === 'Withdraw' || event.event_type === 'EmergencyWithdraw') { if (state.shares <= 0) { return; } @@ -126,11 +119,7 @@ function applyDepositorEvent( } } -function computeApy( - netYield: number, - depositedValue: number, - daysElapsed: number, -): number { +function computeApy(netYield: number, depositedValue: number, daysElapsed: number): number { if (depositedValue <= 0 || daysElapsed <= 0) { return 0; } @@ -139,12 +128,8 @@ function computeApy( return parseFloat((annualized * 100).toFixed(4)); } -export function normalizeYieldHistoryDays( - days: number | undefined, -): YieldHistoryDayRange { - if ( - (YIELD_HISTORY_ALLOWED_DAYS as readonly number[]).includes(days as number) - ) { +export function normalizeYieldHistoryDays(days: number | undefined): YieldHistoryDayRange { + if ((YIELD_HISTORY_ALLOWED_DAYS as readonly number[]).includes(days as number)) { return days as YieldHistoryDayRange; } return YIELD_HISTORY_DEFAULT_DAYS; @@ -159,7 +144,7 @@ export async function buildDepositorYieldHistory( const since = new Date(); since.setUTCDate(since.getUTCDate() - days); - const poolContractId = process.env.LENDING_POOL_CONTRACT_ID ?? ""; + const poolContractId = process.env.LENDING_POOL_CONTRACT_ID ?? ''; const [poolEventsResult, depositorEventsResult] = await Promise.all([ query( @@ -199,11 +184,7 @@ export async function buildDepositorYieldHistory( const start = new Date(since); start.setUTCHours(0, 0, 0, 0); - for ( - let cursor = new Date(start); - cursor <= end; - cursor.setUTCDate(cursor.getUTCDate() + 1) - ) { + for (let cursor = new Date(start); cursor <= end; cursor.setUTCDate(cursor.getUTCDate() + 1)) { bucketDates.push(new Date(cursor)); if (bucketDates.length >= MAX_POINTS) { break; @@ -234,11 +215,7 @@ export async function buildDepositorYieldHistory( depositorIdx < depositorEvents.length && new Date(depositorEvents[depositorIdx]!.ledger_closed_at) <= bucketEnd ) { - applyDepositorEvent( - depositorState, - poolState, - depositorEvents[depositorIdx]!, - ); + applyDepositorEvent(depositorState, poolState, depositorEvents[depositorIdx]!); depositorIdx += 1; } @@ -248,15 +225,9 @@ export async function buildDepositorYieldHistory( poolState.totalShares, ); - const isLastBucket = - bucketEnd.getTime() === bucketDates[bucketDates.length - 1]!.getTime(); - if ( - isLastBucket && - currentSharePrice !== undefined && - depositorState.shares > 0 - ) { - currentValue = - (depositorState.shares * currentSharePrice) / SHARE_PRICE_SCALE; + const isLastBucket = bucketEnd.getTime() === bucketDates[bucketDates.length - 1]!.getTime(); + if (isLastBucket && currentSharePrice !== undefined && depositorState.shares > 0) { + currentValue = (depositorState.shares * currentSharePrice) / SHARE_PRICE_SCALE; } const depositedValue = depositorState.costBasis; @@ -270,9 +241,7 @@ export async function buildDepositorYieldHistory( }); } - return points.filter( - (point) => point.depositedValue > 0 || point.currentValue > 0, - ); + return points.filter((point) => point.depositedValue > 0 || point.currentValue > 0); } export { MAX_POINTS, computeApy }; diff --git a/backend/src/tests/auditLog.test.ts b/backend/src/tests/auditLog.test.ts index 75a3b70b..f267ef81 100644 --- a/backend/src/tests/auditLog.test.ts +++ b/backend/src/tests/auditLog.test.ts @@ -1,7 +1,7 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; // Use unstable_mockModule for robust ESM mocking of the connection module. -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), default: { query: jest.fn(), @@ -9,29 +9,29 @@ jest.unstable_mockModule("../db/connection.js", () => ({ })); // Use dynamic imports to ensure mocks are applied BEFORE the app as well as the test -const { query } = await import("../db/connection.js"); -const { auditLog } = await import("../middleware/auditLog.js"); -import type { Request, Response, NextFunction } from "express"; +const { query } = await import('../db/connection.js'); +const { auditLog } = await import('../middleware/auditLog.js'); +import type { Request, Response, NextFunction } from 'express'; const mockedQuery = query as jest.MockedFunction; -describe("Audit Log Middleware", () => { +describe('Audit Log Middleware', () => { let req: Partial; let res: Partial; let next: NextFunction; beforeEach(() => { req = { - method: "POST", - path: "/admin/check-defaults", + method: 'POST', + path: '/admin/check-defaults', headers: { - "x-api-key": "test-api-key", + 'x-api-key': 'test-api-key', }, body: { loanIds: [1, 2, 3], }, - ip: "127.0.0.1", - socket: {} as import("net").Socket, + ip: '127.0.0.1', + socket: {} as import('net').Socket, params: {}, }; res = {}; @@ -39,7 +39,7 @@ describe("Audit Log Middleware", () => { jest.clearAllMocks(); }); - it("should log admin action to audit_logs table", async () => { + it('should log admin action to audit_logs table', async () => { await auditLog(req as Request, res as Response, next); expect(next).toHaveBeenCalled(); @@ -48,20 +48,20 @@ describe("Audit Log Middleware", () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(mockedQuery).toHaveBeenCalledWith( - expect.stringContaining("INSERT INTO audit_logs"), + expect.stringContaining('INSERT INTO audit_logs'), expect.arrayContaining([ - "INTERNAL_API_KEY", - "POST /admin/check-defaults", - "LoanIDs:[1,2,3]", + 'INTERNAL_API_KEY', + 'POST /admin/check-defaults', + 'LoanIDs:[1,2,3]', expect.stringContaining('"loanIds":[1,2,3]'), - "127.0.0.1", + '127.0.0.1', ]), ); }); - it("should redact sensitive fields in payload", async () => { + it('should redact sensitive fields in payload', async () => { req.body = { - secret: "sensitive-data", + secret: 'sensitive-data', loanId: 123, }; @@ -69,12 +69,12 @@ describe("Audit Log Middleware", () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(mockedQuery).toHaveBeenCalledWith( - expect.stringContaining("INSERT INTO audit_logs"), + expect.stringContaining('INSERT INTO audit_logs'), expect.arrayContaining([ expect.anything(), expect.anything(), - "LoanID:123", - expect.stringContaining("[REDACTED]"), + 'LoanID:123', + expect.stringContaining('[REDACTED]'), expect.anything(), ]), ); @@ -82,28 +82,28 @@ describe("Audit Log Middleware", () => { const callArgs = mockedQuery.mock.calls[0]; const callPayload = callArgs?.[1]?.[3]; - if (typeof callPayload === "string") { + if (typeof callPayload === 'string') { const parsedPayload = JSON.parse(callPayload); - expect(parsedPayload.secret).toBe("[REDACTED]"); + expect(parsedPayload.secret).toBe('[REDACTED]'); expect(parsedPayload.loanId).toBe(123); } else { - throw new Error("Payload was not recorded as a string"); + throw new Error('Payload was not recorded as a string'); } }); - it("should identify actor from JWT if present", async () => { + it('should identify actor from JWT if present', async () => { (req as unknown as { user: { publicKey: string; role: string } }).user = { - publicKey: "G-STUDENT-WALLET-ADDR", - role: "admin", + publicKey: 'G-STUDENT-WALLET-ADDR', + role: 'admin', }; await auditLog(req as Request, res as Response, next); await new Promise((resolve) => setTimeout(resolve, 10)); expect(mockedQuery).toHaveBeenCalledWith( - expect.stringContaining("INSERT INTO audit_logs"), + expect.stringContaining('INSERT INTO audit_logs'), expect.arrayContaining([ - "G-STUDENT-WALLET-ADDR", + 'G-STUDENT-WALLET-ADDR', expect.anything(), expect.anything(), expect.anything(), diff --git a/backend/src/tests/envValidation.test.ts b/backend/src/tests/envValidation.test.ts index 07a42da9..b14a94bd 100644 --- a/backend/src/tests/envValidation.test.ts +++ b/backend/src/tests/envValidation.test.ts @@ -1,15 +1,15 @@ -import { validateEnvVars } from "../config/env.js"; -import { jest } from "@jest/globals"; +import { validateEnvVars } from '../config/env.js'; +import { jest } from '@jest/globals'; -jest.mock("../utils/logger.js"); +jest.mock('../utils/logger.js'); -describe("Environment Variable Validation", () => { +describe('Environment Variable Validation', () => { const originalEnv = process.env; let mockExit: ReturnType; beforeAll(() => { mockExit = jest - .spyOn(process, "exit") + .spyOn(process, 'exit') .mockImplementation((code?: string | number | null | undefined) => { throw new Error(`Process.exit called with ${code}`); }); @@ -26,41 +26,41 @@ describe("Environment Variable Validation", () => { mockExit.mockRestore(); }); - it("should not exit if all required variables are present", () => { + it('should not exit if all required variables are present', () => { // All required variables are expected to be in originalEnv/process.env // or we set them here for the test - process.env.DATABASE_URL = "postgres://localhost"; - process.env.REDIS_URL = "redis://localhost"; - process.env.JWT_SECRET = "secret"; - process.env.STELLAR_RPC_URL = "http://localhost"; - process.env.STELLAR_NETWORK_PASSPHRASE = "test"; - process.env.LOAN_MANAGER_CONTRACT_ID = "C1"; - process.env.LENDING_POOL_CONTRACT_ID = "C2"; - process.env.POOL_TOKEN_ADDRESS = "T1"; - process.env.LOAN_MANAGER_ADMIN_SECRET = "S1"; - process.env.INTERNAL_API_KEY = "K1"; - process.env.FRONTEND_URL = "http://localhost:3000"; - process.env.SCORE_DELTA_REPAY = "15"; - process.env.SCORE_DELTA_DEFAULT = "50"; - process.env.SCORE_DELTA_LATE = "5"; - process.env.REMITTANCE_NFT_CONTRACT_ID = "C3"; - process.env.MULTISIG_GOVERNANCE_CONTRACT_ID = "C4"; + process.env.DATABASE_URL = 'postgres://localhost'; + process.env.REDIS_URL = 'redis://localhost'; + process.env.JWT_SECRET = 'secret'; + process.env.STELLAR_RPC_URL = 'http://localhost'; + process.env.STELLAR_NETWORK_PASSPHRASE = 'test'; + process.env.LOAN_MANAGER_CONTRACT_ID = 'C1'; + process.env.LENDING_POOL_CONTRACT_ID = 'C2'; + process.env.POOL_TOKEN_ADDRESS = 'T1'; + process.env.LOAN_MANAGER_ADMIN_SECRET = 'S1'; + process.env.INTERNAL_API_KEY = 'K1'; + process.env.FRONTEND_URL = 'http://localhost:3000'; + process.env.SCORE_DELTA_REPAY = '15'; + process.env.SCORE_DELTA_DEFAULT = '50'; + process.env.SCORE_DELTA_LATE = '5'; + process.env.REMITTANCE_NFT_CONTRACT_ID = 'C3'; + process.env.MULTISIG_GOVERNANCE_CONTRACT_ID = 'C4'; expect(() => validateEnvVars()).not.toThrow(); expect(mockExit).not.toHaveBeenCalled(); }); - it("should exit with code 1 if a required variable is missing", () => { + it('should exit with code 1 if a required variable is missing', () => { delete process.env.DATABASE_URL; - expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); + expect(() => validateEnvVars()).toThrow('Process.exit called with 1'); expect(mockExit).toHaveBeenCalledWith(1); }); - it("should exit with code 1 if a required variable is empty string", () => { - process.env.DATABASE_URL = " "; + it('should exit with code 1 if a required variable is empty string', () => { + process.env.DATABASE_URL = ' '; - expect(() => validateEnvVars()).toThrow("Process.exit called with 1"); + expect(() => validateEnvVars()).toThrow('Process.exit called with 1'); expect(mockExit).toHaveBeenCalledWith(1); }); }); diff --git a/backend/src/tests/health.test.ts b/backend/src/tests/health.test.ts index a0a492cb..9728e239 100644 --- a/backend/src/tests/health.test.ts +++ b/backend/src/tests/health.test.ts @@ -1,11 +1,11 @@ -import request from "supertest"; -import app from "../app.js"; +import request from 'supertest'; +import app from '../app.js'; -describe("Health Check Endpoint", () => { - it("should return 200 and a running message on GET /", async () => { - const response = await request(app).get("/"); +describe('Health Check Endpoint', () => { + it('should return 200 and a running message on GET /', async () => { + const response = await request(app).get('/'); expect(response.status).toBe(200); - expect(response.text).toBe("RemitLend Backend is running"); + expect(response.text).toBe('RemitLend Backend is running'); }); }); diff --git a/backend/src/tests/idempotency.test.ts b/backend/src/tests/idempotency.test.ts index e9d33f80..155a79c9 100644 --- a/backend/src/tests/idempotency.test.ts +++ b/backend/src/tests/idempotency.test.ts @@ -1,28 +1,28 @@ -import { Request, Response, NextFunction } from "express"; -import { idempotencyMiddleware } from "../middleware/idempotency.js"; -import { cacheService } from "../services/cacheService.js"; -import { jest } from "@jest/globals"; +import { Request, Response, NextFunction } from 'express'; +import { idempotencyMiddleware } from '../middleware/idempotency.js'; +import { cacheService } from '../services/cacheService.js'; +import { jest } from '@jest/globals'; // Helper to cast to jest.Mock const asMock = (fn: unknown) => fn as jest.Mock; -describe("Idempotency Middleware", () => { +describe('Idempotency Middleware', () => { let req: Partial; let res: Partial; let next: NextFunction; beforeEach(() => { req = { - header: jest.fn() as unknown as Request["header"], - method: "POST", - originalUrl: "/api/test", + header: jest.fn() as unknown as Request['header'], + method: 'POST', + originalUrl: '/api/test', }; res = { - status: jest.fn().mockReturnThis() as unknown as Response["status"], - set: jest.fn().mockReturnThis() as unknown as Response["set"], - json: jest.fn().mockReturnThis() as unknown as Response["json"], - send: jest.fn().mockReturnThis() as unknown as Response["send"], - on: jest.fn() as unknown as Response["on"], + status: jest.fn().mockReturnThis() as unknown as Response['status'], + set: jest.fn().mockReturnThis() as unknown as Response['set'], + json: jest.fn().mockReturnThis() as unknown as Response['json'], + send: jest.fn().mockReturnThis() as unknown as Response['send'], + on: jest.fn() as unknown as Response['on'], statusCode: 200, }; next = jest.fn(); @@ -30,15 +30,15 @@ describe("Idempotency Middleware", () => { // Mock cacheService explicitly for each test if needed // In ESM with Jest, mocking can be tricky, so we rely on manual mocks of the singleton instance if possible // or use jest.spyOn if the instance is exported. - jest.spyOn(cacheService, "get").mockReset(); - jest.spyOn(cacheService, "set").mockReset(); + jest.spyOn(cacheService, 'get').mockReset(); + jest.spyOn(cacheService, 'set').mockReset(); }); afterEach(() => { jest.restoreAllMocks(); }); - it("should call next() if no Idempotency-Key is present", async () => { + it('should call next() if no Idempotency-Key is present', async () => { asMock(req.header).mockReturnValue(undefined); await idempotencyMiddleware(req as Request, res as Response, next); @@ -47,60 +47,52 @@ describe("Idempotency Middleware", () => { expect(cacheService.get).not.toHaveBeenCalled(); }); - it("should return cached response if key exists", async () => { - const key = "test-key"; + it('should return cached response if key exists', async () => { + const key = 'test-key'; const cachedResponse = { status: 201, body: { success: true } }; asMock(req.header).mockReturnValue(key); - (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue( - cachedResponse, - ); + (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue(cachedResponse); await idempotencyMiddleware(req as Request, res as Response, next); expect(cacheService.get).toHaveBeenCalledWith(`idemp:${key}`); expect(res.status).toHaveBeenCalledWith(201); - expect(res.set).toHaveBeenCalledWith("X-Idempotency-Cache", "HIT"); + expect(res.set).toHaveBeenCalledWith('X-Idempotency-Cache', 'HIT'); expect(res.json).toHaveBeenCalledWith(cachedResponse.body); expect(next).not.toHaveBeenCalled(); }); - it("sets X-Idempotent-Replayed: true on a cache hit (replayed response)", async () => { - const key = "replay-key"; + it('sets X-Idempotent-Replayed: true on a cache hit (replayed response)', async () => { + const key = 'replay-key'; const cachedResponse = { status: 200, body: { id: 99 } }; asMock(req.header).mockReturnValue(key); - (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue( - cachedResponse, - ); + (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue(cachedResponse); await idempotencyMiddleware(req as Request, res as Response, next); - expect(res.set).toHaveBeenCalledWith("X-Idempotent-Replayed", "true"); + expect(res.set).toHaveBeenCalledWith('X-Idempotent-Replayed', 'true'); expect(next).not.toHaveBeenCalled(); }); - it("sets X-Idempotent-Replayed: false on a fresh (cache miss) execution", async () => { - const key = "fresh-key"; + it('sets X-Idempotent-Replayed: false on a fresh (cache miss) execution', async () => { + const key = 'fresh-key'; asMock(req.header).mockReturnValue(key); - (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue( - null, - ); + (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue(null); await idempotencyMiddleware(req as Request, res as Response, next); - expect(res.set).toHaveBeenCalledWith("X-Idempotent-Replayed", "false"); + expect(res.set).toHaveBeenCalledWith('X-Idempotent-Replayed', 'false'); expect(next).toHaveBeenCalled(); }); - it("should proceed and intercept response on cache miss", async () => { - const key = "new-key"; + it('should proceed and intercept response on cache miss', async () => { + const key = 'new-key'; asMock(req.header).mockReturnValue(key); - (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue( - null, - ); + (cacheService.get as jest.Mock<() => Promise>).mockResolvedValue(null); await idempotencyMiddleware(req as Request, res as Response, next); expect(next).toHaveBeenCalled(); - expect(res.on).toHaveBeenCalledWith("finish", expect.any(Function)); + expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function)); }); }); diff --git a/backend/src/tests/notificationCleanup.test.ts b/backend/src/tests/notificationCleanup.test.ts index 01af146c..2857b571 100644 --- a/backend/src/tests/notificationCleanup.test.ts +++ b/backend/src/tests/notificationCleanup.test.ts @@ -1,7 +1,7 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; // Use unstable_mockModule for robust ESM mocking of the connection module -jest.unstable_mockModule("../db/connection.js", () => ({ +jest.unstable_mockModule('../db/connection.js', () => ({ query: jest.fn(), default: { query: jest.fn(), @@ -9,44 +9,42 @@ jest.unstable_mockModule("../db/connection.js", () => ({ })); // Use dynamic imports TO ENSURE mocks are applied BEFORE the module is loaded -const { query } = await import("../db/connection.js"); -const { notificationService } = - await import("../services/notificationService.js"); +const { query } = await import('../db/connection.js'); +const { notificationService } = await import('../services/notificationService.js'); const mockedQuery = query as jest.MockedFunction; -describe("Notification Cleanup Strategy", () => { +describe('Notification Cleanup Strategy', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe("deleteOldNotifications", () => { - it("should delete notifications older than the retention threshold", async () => { + describe('deleteOldNotifications', () => { + it('should delete notifications older than the retention threshold', async () => { const retentionDays = 90; mockedQuery.mockResolvedValue({ rows: [], rowCount: 2, - command: "DELETE", + command: 'DELETE', oid: 0, fields: [], }); - const deletedCount = - await notificationService.deleteOldNotifications(retentionDays); + const deletedCount = await notificationService.deleteOldNotifications(retentionDays); expect(mockedQuery).toHaveBeenCalledWith( - expect.stringContaining("DELETE FROM notifications"), + expect.stringContaining('DELETE FROM notifications'), [retentionDays], ); expect(deletedCount).toBe(2); }); - it("should return 0 if no notifications are deleted", async () => { + it('should return 0 if no notifications are deleted', async () => { mockedQuery.mockResolvedValue({ rows: [], rowCount: 0, - command: "DELETE", + command: 'DELETE', oid: 0, fields: [], }); @@ -56,8 +54,8 @@ describe("Notification Cleanup Strategy", () => { expect(deletedCount).toBe(0); }); - it("should handle database errors gracefully", async () => { - mockedQuery.mockRejectedValue(new Error("Database error") as never); + it('should handle database errors gracefully', async () => { + mockedQuery.mockRejectedValue(new Error('Database error') as never); const deletedCount = await notificationService.deleteOldNotifications(90); @@ -65,20 +63,19 @@ describe("Notification Cleanup Strategy", () => { }); }); - describe("deleteReadAndArchived", () => { - it("should delete read and archived notifications older than the retention threshold", async () => { + describe('deleteReadAndArchived', () => { + it('should delete read and archived notifications older than the retention threshold', async () => { const retentionDays = 30; mockedQuery.mockResolvedValue({ rows: [], rowCount: 5, - command: "DELETE", + command: 'DELETE', oid: 0, fields: [], }); - const deletedCount = - await notificationService.deleteReadAndArchived(retentionDays); + const deletedCount = await notificationService.deleteReadAndArchived(retentionDays); expect(mockedQuery).toHaveBeenCalledWith( expect.stringContaining("status IN ('read', 'archived')"), @@ -87,11 +84,11 @@ describe("Notification Cleanup Strategy", () => { expect(deletedCount).toBe(5); }); - it("should return 0 if no read/archived notifications are deleted", async () => { + it('should return 0 if no read/archived notifications are deleted', async () => { mockedQuery.mockResolvedValue({ rows: [], rowCount: 0, - command: "DELETE", + command: 'DELETE', oid: 0, fields: [], }); @@ -101,8 +98,8 @@ describe("Notification Cleanup Strategy", () => { expect(deletedCount).toBe(0); }); - it("should handle database errors gracefully", async () => { - mockedQuery.mockRejectedValue(new Error("Database error") as never); + it('should handle database errors gracefully', async () => { + mockedQuery.mockRejectedValue(new Error('Database error') as never); const deletedCount = await notificationService.deleteReadAndArchived(30); @@ -110,60 +107,59 @@ describe("Notification Cleanup Strategy", () => { }); }); - describe("archiveNotifications", () => { - it("should set status to archived and read to true for the given ids", async () => { + describe('archiveNotifications', () => { + it('should set status to archived and read to true for the given ids', async () => { mockedQuery.mockResolvedValue({ rows: [], rowCount: 2, - command: "UPDATE", + command: 'UPDATE', oid: 0, fields: [], }); - await notificationService.archiveNotifications("user-1", [1, 2]); + await notificationService.archiveNotifications('user-1', [1, 2]); - expect(mockedQuery).toHaveBeenCalledWith( - expect.stringContaining("status = 'archived'"), - ["user-1", [1, 2]], - ); + expect(mockedQuery).toHaveBeenCalledWith(expect.stringContaining("status = 'archived'"), [ + 'user-1', + [1, 2], + ]); }); - it("should not query the database when ids array is empty", async () => { - await notificationService.archiveNotifications("user-1", []); + it('should not query the database when ids array is empty', async () => { + await notificationService.archiveNotifications('user-1', []); expect(mockedQuery).not.toHaveBeenCalled(); }); }); - describe("getUnreadCount", () => { - it("should count notifications with status unread", async () => { + describe('getUnreadCount', () => { + it('should count notifications with status unread', async () => { mockedQuery.mockResolvedValue({ - rows: [{ count: "3" }], + rows: [{ count: '3' }], rowCount: 1, - command: "SELECT", + command: 'SELECT', oid: 0, fields: [], }); - const count = await notificationService.getUnreadCount("user-1"); + const count = await notificationService.getUnreadCount('user-1'); - expect(mockedQuery).toHaveBeenCalledWith( - expect.stringContaining("status = 'unread'"), - ["user-1"], - ); + expect(mockedQuery).toHaveBeenCalledWith(expect.stringContaining("status = 'unread'"), [ + 'user-1', + ]); expect(count).toBe(3); }); - it("should return 0 when there are no unread notifications", async () => { + it('should return 0 when there are no unread notifications', async () => { mockedQuery.mockResolvedValue({ - rows: [{ count: "0" }], + rows: [{ count: '0' }], rowCount: 1, - command: "SELECT", + command: 'SELECT', oid: 0, fields: [], }); - const count = await notificationService.getUnreadCount("user-1"); + const count = await notificationService.getUnreadCount('user-1'); expect(count).toBe(0); }); diff --git a/backend/src/utils/__tests__/queryHelpers.test.ts b/backend/src/utils/__tests__/queryHelpers.test.ts index abf6a415..c2ac86dd 100644 --- a/backend/src/utils/__tests__/queryHelpers.test.ts +++ b/backend/src/utils/__tests__/queryHelpers.test.ts @@ -1,48 +1,48 @@ -import { parseCappedLimit } from "../queryHelpers.js"; -import type { Request } from "express"; +import { parseCappedLimit } from '../queryHelpers.js'; +import type { Request } from 'express'; -describe("parseCappedLimit", () => { +describe('parseCappedLimit', () => { const mockRequest = (queryLimit: string | undefined): Partial => ({ query: { limit: queryLimit }, }); - it("should return default limit when no limit is provided", () => { + it('should return default limit when no limit is provided', () => { const req = mockRequest(undefined) as Request; expect(parseCappedLimit(req, 20)).toBe(20); }); - it("should return provided limit when within MAX_LIMIT", () => { - const req = mockRequest("50") as Request; + it('should return provided limit when within MAX_LIMIT', () => { + const req = mockRequest('50') as Request; expect(parseCappedLimit(req, 20)).toBe(50); }); - it("should cap at MAX_LIMIT when provided limit exceeds MAX_LIMIT", () => { - const req = mockRequest("1000000") as Request; + it('should cap at MAX_LIMIT when provided limit exceeds MAX_LIMIT', () => { + const req = mockRequest('1000000') as Request; expect(parseCappedLimit(req, 20)).toBe(100); }); - it("should return default limit when provided limit is invalid", () => { - const req = mockRequest("invalid") as Request; + it('should return default limit when provided limit is invalid', () => { + const req = mockRequest('invalid') as Request; expect(parseCappedLimit(req, 20)).toBe(20); }); - it("should return default limit when provided limit is negative", () => { - const req = mockRequest("-10") as Request; + it('should return default limit when provided limit is negative', () => { + const req = mockRequest('-10') as Request; expect(parseCappedLimit(req, 20)).toBe(20); }); - it("should return default limit when provided limit is zero", () => { - const req = mockRequest("0") as Request; + it('should return default limit when provided limit is zero', () => { + const req = mockRequest('0') as Request; expect(parseCappedLimit(req, 20)).toBe(20); }); - it("should handle edge case of exactly MAX_LIMIT", () => { - const req = mockRequest("100") as Request; + it('should handle edge case of exactly MAX_LIMIT', () => { + const req = mockRequest('100') as Request; expect(parseCappedLimit(req, 20)).toBe(100); }); - it("should handle decimal numbers by treating them as invalid", () => { - const req = mockRequest("50.5") as Request; + it('should handle decimal numbers by treating them as invalid', () => { + const req = mockRequest('50.5') as Request; expect(parseCappedLimit(req, 20)).toBe(20); }); }); diff --git a/backend/src/utils/__tests__/stellar.test.ts b/backend/src/utils/__tests__/stellar.test.ts index 33370501..5d3c38fe 100644 --- a/backend/src/utils/__tests__/stellar.test.ts +++ b/backend/src/utils/__tests__/stellar.test.ts @@ -1,6 +1,6 @@ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; -describe("Stellar utils", () => { +describe('Stellar utils', () => { const originalEnv = { ...process.env }; beforeEach(() => { @@ -11,31 +11,28 @@ describe("Stellar utils", () => { process.env = originalEnv; }); - describe("isValidStellarAddress", () => { - it("returns true for a valid 56-char G... address", async () => { - const { isValidStellarAddress } = await import("../stellar.js"); - const validAddress = - "GBUQWP3BOUZX34ULNQG23RQ6F4BVWCIBTLFL2F7HVRQG5LDHNWY2QTWA"; + describe('isValidStellarAddress', () => { + it('returns true for a valid 56-char G... address', async () => { + const { isValidStellarAddress } = await import('../stellar.js'); + const validAddress = 'GBUQWP3BOUZX34ULNQG23RQ6F4BVWCIBTLFL2F7HVRQG5LDHNWY2QTWA'; expect(isValidStellarAddress(validAddress)).toBe(true); }); - it("returns false for wrong length / wrong prefix / lowercase / invalid base32", async () => { - const { isValidStellarAddress } = await import("../stellar.js"); + it('returns false for wrong length / wrong prefix / lowercase / invalid base32', async () => { + const { isValidStellarAddress } = await import('../stellar.js'); - expect(isValidStellarAddress("")).toBe(false); - expect(isValidStellarAddress("G".repeat(55))).toBe(false); - expect(isValidStellarAddress("G".repeat(57))).toBe(false); - expect(isValidStellarAddress("A".repeat(56))).toBe(false); + expect(isValidStellarAddress('')).toBe(false); + expect(isValidStellarAddress('G'.repeat(55))).toBe(false); + expect(isValidStellarAddress('G'.repeat(57))).toBe(false); + expect(isValidStellarAddress('A'.repeat(56))).toBe(false); expect( - isValidStellarAddress( - "gbuqwp3bouzx34ulnqg23rq6f4bvwcibtlfl2f7hvrqg5ldhnwy2qtwa", - ), + isValidStellarAddress('gbuqwp3bouzx34ulnqg23rq6f4bvwcibtlfl2f7hvrqg5ldhnwy2qtwa'), ).toBe(false); - expect(isValidStellarAddress(`G${"A".repeat(50)}0123`)).toBe(false); + expect(isValidStellarAddress(`G${'A'.repeat(50)}0123`)).toBe(false); }); - it("returns false for non-string values", async () => { - const { isValidStellarAddress } = await import("../stellar.js"); + it('returns false for non-string values', async () => { + const { isValidStellarAddress } = await import('../stellar.js'); expect(isValidStellarAddress(null)).toBe(false); expect(isValidStellarAddress(undefined)).toBe(false); expect(isValidStellarAddress(123)).toBe(false); @@ -43,32 +40,27 @@ describe("Stellar utils", () => { }); }); - describe("assertValidStellarAddress", () => { - it("throws on invalid", async () => { - const { assertValidStellarAddress } = await import("../stellar.js"); - expect(() => assertValidStellarAddress("not-an-address")).toThrow( - "Invalid Stellar address", - ); + describe('assertValidStellarAddress', () => { + it('throws on invalid', async () => { + const { assertValidStellarAddress } = await import('../stellar.js'); + expect(() => assertValidStellarAddress('not-an-address')).toThrow('Invalid Stellar address'); }); - it("passes through valid", async () => { - const { assertValidStellarAddress } = await import("../stellar.js"); - const validAddress = - "GBHVTBKMJ5PXJW7VDBLCWVYXCXU6BFJFNX4S3HJQEWQYXU2CKFCW4FAA"; + it('passes through valid', async () => { + const { assertValidStellarAddress } = await import('../stellar.js'); + const validAddress = 'GBHVTBKMJ5PXJW7VDBLCWVYXCXU6BFJFNX4S3HJQEWQYXU2CKFCW4FAA'; expect(() => assertValidStellarAddress(validAddress)).not.toThrow(); }); }); - describe("getTxUrl / getAccountUrl", () => { - it("builds explorer URLs and honors STELLAR_EXPLORER_URL", async () => { - process.env.STELLAR_EXPLORER_URL = "https://example.com/explorer"; + describe('getTxUrl / getAccountUrl', () => { + it('builds explorer URLs and honors STELLAR_EXPLORER_URL', async () => { + process.env.STELLAR_EXPLORER_URL = 'https://example.com/explorer'; jest.resetModules(); - const { getTxUrl, getAccountUrl } = await import("../stellar.js"); + const { getTxUrl, getAccountUrl } = await import('../stellar.js'); - expect(getTxUrl("txhash")).toBe("https://example.com/explorer/tx/txhash"); - expect(getAccountUrl("GABC")).toBe( - "https://example.com/explorer/account/GABC", - ); + expect(getTxUrl('txhash')).toBe('https://example.com/explorer/tx/txhash'); + expect(getAccountUrl('GABC')).toBe('https://example.com/explorer/account/GABC'); }); }); }); diff --git a/backend/src/utils/asyncHandler.ts b/backend/src/utils/asyncHandler.ts index 7356a0b3..313b4a31 100644 --- a/backend/src/utils/asyncHandler.ts +++ b/backend/src/utils/asyncHandler.ts @@ -1,4 +1,4 @@ -import type { Request, Response, NextFunction, RequestHandler } from "express"; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; export const asyncHandler = ( fn: (req: Request, res: Response, next: NextFunction) => Promise | void, diff --git a/backend/src/utils/cache.ts b/backend/src/utils/cache.ts index 19db4457..f969ca05 100644 --- a/backend/src/utils/cache.ts +++ b/backend/src/utils/cache.ts @@ -1,15 +1,13 @@ -import { createClient } from "redis"; -import logger from "../utils/logger.js"; +import { createClient } from 'redis'; +import logger from '../utils/logger.js'; -const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379"; +const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'; export const redisClient = createClient({ url: redisUrl }); -redisClient.on("error", (err: Error) => - logger.error("Redis client error", err), -); +redisClient.on('error', (err: Error) => logger.error('Redis client error', err)); -redisClient.on("connect", () => logger.info("Redis client connected")); +redisClient.on('connect', () => logger.info('Redis client connected')); // Connect lazily on first use so the app doesn't crash if Redis is absent let _connected = false; @@ -29,7 +27,7 @@ export const getCache = async (key: string): Promise => { const raw = await redisClient.get(key); return raw ? (JSON.parse(raw) as T) : null; } catch (err) { - logger.warn("Redis getCache error — bypassing cache", { key, err }); + logger.warn('Redis getCache error — bypassing cache', { key, err }); return null; } }; @@ -37,15 +35,11 @@ export const getCache = async (key: string): Promise => { /** * Store a JSON value with an optional TTL in seconds (default: 30 s). */ -export const setCache = async ( - key: string, - value: unknown, - ttlSeconds = 30, -): Promise => { +export const setCache = async (key: string, value: unknown, ttlSeconds = 30): Promise => { try { await ensureConnected(); await redisClient.set(key, JSON.stringify(value), { EX: ttlSeconds }); } catch (err) { - logger.warn("Redis setCache error — skipping cache write", { key, err }); + logger.warn('Redis setCache error — skipping cache write', { key, err }); } }; diff --git a/backend/src/utils/cacheKeys.ts b/backend/src/utils/cacheKeys.ts index 81d298dd..f567b157 100644 --- a/backend/src/utils/cacheKeys.ts +++ b/backend/src/utils/cacheKeys.ts @@ -1,4 +1,4 @@ -import { cacheService } from "../services/cacheService.js"; +import { cacheService } from '../services/cacheService.js'; /** * Canonical cache key generators. @@ -7,7 +7,7 @@ import { cacheService } from "../services/cacheService.js"; */ export const CacheKeys = { // Pool stats aggregate (getPoolStats) - poolStats: () => "pool:stats", + poolStats: () => 'pool:stats', // Per-borrower loans aggregate (getBorrowerLoans) borrowerLoans: (borrower: string) => `borrower:loans:${borrower}`, @@ -16,8 +16,7 @@ export const CacheKeys = { scoreBreakdown: (publicKey: string) => `score:breakdown:${publicKey}`, // Idempotency / unsigned-tx keys – loan - pendingLoanTx: (borrower: string, amount: number) => - `pending_loan_tx:${borrower}:${amount}`, + pendingLoanTx: (borrower: string, amount: number) => `pending_loan_tx:${borrower}:${amount}`, pendingRepayTx: (borrower: string, loanId: number, amount: number) => `pending_repay_tx:${borrower}:${loanId}:${amount}`, @@ -34,10 +33,7 @@ export const CacheKeys = { * Invalidate all cache keys that become stale after a repayment. * Call this after the DB transaction commits inside repayLoan. */ -export async function invalidateOnRepay( - borrower: string, - _loanId: number, -): Promise { +export async function invalidateOnRepay(borrower: string, _loanId: number): Promise { await Promise.all([ cacheService.delete(CacheKeys.poolStats()), cacheService.delete(CacheKeys.borrowerLoans(borrower)), diff --git a/backend/src/utils/demo-rate-limit.ts b/backend/src/utils/demo-rate-limit.ts index 6f47cba0..6325b961 100644 --- a/backend/src/utils/demo-rate-limit.ts +++ b/backend/src/utils/demo-rate-limit.ts @@ -1,32 +1,22 @@ -import { - rateLimitService, - SCORE_UPDATE_RATE_LIMIT, -} from "../services/rateLimitService.js"; +import { rateLimitService, SCORE_UPDATE_RATE_LIMIT } from '../services/rateLimitService.js'; /** * Demo script to show rate limiting functionality for score updates */ async function demonstrateRateLimiting() { - console.log("=== Rate Limiting Demo for Score Updates ===\n"); + console.log('=== Rate Limiting Demo for Score Updates ===\n'); - const userId = "demo-user-123"; + const userId = 'demo-user-123'; console.log(`Configuration:`); - console.log( - `- Max requests per user per day: ${SCORE_UPDATE_RATE_LIMIT.maxRequests}`, - ); - console.log( - `- Window duration: ${SCORE_UPDATE_RATE_LIMIT.windowSeconds} seconds (24 hours)`, - ); + console.log(`- Max requests per user per day: ${SCORE_UPDATE_RATE_LIMIT.maxRequests}`); + console.log(`- Window duration: ${SCORE_UPDATE_RATE_LIMIT.windowSeconds} seconds (24 hours)`); console.log(`- User ID: ${userId}`); console.log(); // Test 1: First request should be allowed - console.log("1. First request (should be allowed):"); - const result1 = await rateLimitService.checkRateLimit( - userId, - SCORE_UPDATE_RATE_LIMIT, - ); + console.log('1. First request (should be allowed):'); + const result1 = await rateLimitService.checkRateLimit(userId, SCORE_UPDATE_RATE_LIMIT); console.log(` Allowed: ${result1.allowed}`); console.log(` Remaining: ${result1.remaining}`); console.log(` Current count: ${result1.currentCount}`); @@ -34,12 +24,9 @@ async function demonstrateRateLimiting() { console.log(); // Test 2: Several more requests within limit - console.log("2-4. Making 3 more requests (should be allowed):"); + console.log('2-4. Making 3 more requests (should be allowed):'); for (let i = 2; i <= 4; i++) { - const result = await rateLimitService.checkRateLimit( - userId, - SCORE_UPDATE_RATE_LIMIT, - ); + const result = await rateLimitService.checkRateLimit(userId, SCORE_UPDATE_RATE_LIMIT); console.log( ` Request ${i}: Allowed=${result.allowed}, Remaining=${result.remaining}, Count=${result.currentCount}`, ); @@ -47,43 +34,32 @@ async function demonstrateRateLimiting() { console.log(); // Test 3: Check status without incrementing - console.log("5. Current rate limit status (without incrementing):"); - const status = await rateLimitService.getRateLimitStatus( - userId, - SCORE_UPDATE_RATE_LIMIT, - ); + console.log('5. Current rate limit status (without incrementing):'); + const status = await rateLimitService.getRateLimitStatus(userId, SCORE_UPDATE_RATE_LIMIT); console.log(` Allowed: ${status.allowed}`); console.log(` Remaining: ${status.remaining}`); console.log(` Reset time: ${status.resetTime.toISOString()}`); console.log(); // Test 4: Final request that hits the limit - console.log("6. Final request (should hit the limit):"); - const result6 = await rateLimitService.checkRateLimit( - userId, - SCORE_UPDATE_RATE_LIMIT, - ); + console.log('6. Final request (should hit the limit):'); + const result6 = await rateLimitService.checkRateLimit(userId, SCORE_UPDATE_RATE_LIMIT); console.log(` Allowed: ${result6.allowed}`); console.log(` Remaining: ${result6.remaining}`); console.log(` Current count: ${result6.currentCount}`); console.log(); // Test 5: Request beyond limit (should be blocked) - console.log("7. Request beyond limit (should be blocked):"); - const result7 = await rateLimitService.checkRateLimit( - userId, - SCORE_UPDATE_RATE_LIMIT, - ); + console.log('7. Request beyond limit (should be blocked):'); + const result7 = await rateLimitService.checkRateLimit(userId, SCORE_UPDATE_RATE_LIMIT); console.log(` Allowed: ${result7.allowed}`); console.log(` Remaining: ${result7.remaining}`); console.log(` Current count: ${result7.currentCount}`); console.log(); // Test 6: Different user should have independent limit - const differentUserId = "demo-user-456"; - console.log( - `8. Different user (${differentUserId}) should have independent limit:`, - ); + const differentUserId = 'demo-user-456'; + console.log(`8. Different user (${differentUserId}) should have independent limit:`); const resultDifferent = await rateLimitService.checkRateLimit( differentUserId, SCORE_UPDATE_RATE_LIMIT, @@ -94,30 +70,23 @@ async function demonstrateRateLimiting() { console.log(); // Test 7: Reset rate limit - console.log("9. Resetting rate limit for first user..."); + console.log('9. Resetting rate limit for first user...'); await rateLimitService.resetRateLimit(userId); - console.log(" Reset completed"); + console.log(' Reset completed'); console.log(); // Test 8: First request after reset - console.log("10. First request after reset (should be allowed):"); - const resultAfterReset = await rateLimitService.checkRateLimit( - userId, - SCORE_UPDATE_RATE_LIMIT, - ); + console.log('10. First request after reset (should be allowed):'); + const resultAfterReset = await rateLimitService.checkRateLimit(userId, SCORE_UPDATE_RATE_LIMIT); console.log(` Allowed: ${resultAfterReset.allowed}`); console.log(` Remaining: ${resultAfterReset.remaining}`); console.log(` Current count: ${resultAfterReset.currentCount}`); console.log(); - console.log("=== Security Impact ==="); - console.log( - "Before fix: Compromised API key could spam unlimited score updates", - ); - console.log("After fix: Maximum 5 score updates per user per day"); - console.log( - "This prevents score inflation attacks while allowing legitimate usage.", - ); + console.log('=== Security Impact ==='); + console.log('Before fix: Compromised API key could spam unlimited score updates'); + console.log('After fix: Maximum 5 score updates per user per day'); + console.log('This prevents score inflation attacks while allowing legitimate usage.'); } // Run the demonstration diff --git a/backend/src/utils/demo.ts b/backend/src/utils/demo.ts index f4b97e94..d0503669 100644 --- a/backend/src/utils/demo.ts +++ b/backend/src/utils/demo.ts @@ -1,20 +1,20 @@ -import { parseCappedLimit } from "./queryHelpers.js"; -import type { Request } from "express"; +import { parseCappedLimit } from './queryHelpers.js'; +import type { Request } from 'express'; // Demo script to show how the limit capping prevents database performance issues function demonstrateLimitCapping() { - console.log("=== API Security: Limit Query Parameter Capping ===\n"); + console.log('=== API Security: Limit Query Parameter Capping ===\n'); // Simulate different request scenarios const scenarios = [ - { name: "Normal request", limit: "20" }, - { name: "High but reasonable limit", limit: "75" }, - { name: "Dangerous high limit (before fix)", limit: "1000000" }, - { name: "Negative limit (invalid)", limit: "-10" }, - { name: "Zero limit (invalid)", limit: "0" }, - { name: "Decimal limit (invalid)", limit: "50.5" }, - { name: "Non-numeric limit (invalid)", limit: "abc" }, - { name: "No limit provided", limit: undefined }, + { name: 'Normal request', limit: '20' }, + { name: 'High but reasonable limit', limit: '75' }, + { name: 'Dangerous high limit (before fix)', limit: '1000000' }, + { name: 'Negative limit (invalid)', limit: '-10' }, + { name: 'Zero limit (invalid)', limit: '0' }, + { name: 'Decimal limit (invalid)', limit: '50.5' }, + { name: 'Non-numeric limit (invalid)', limit: 'abc' }, + { name: 'No limit provided', limit: undefined }, ]; scenarios.forEach((scenario) => { @@ -24,23 +24,15 @@ function demonstrateLimitCapping() { console.log(`${scenario.name}:`); console.log(` Input: limit=${scenario.limit}`); console.log(` Output: ${effectiveLimit}`); - console.log( - ` Status: ${effectiveLimit <= 100 ? "✅ SAFE" : "❌ DANGEROUS"}`, - ); + console.log(` Status: ${effectiveLimit <= 100 ? '✅ SAFE' : '❌ DANGEROUS'}`); console.log(); }); - console.log("=== Security Impact ==="); - console.log( - "Before fix: limit=1000000 could trigger full table scan → DB crash", - ); - console.log( - "After fix: limit=1000000 is capped to 100 → DB remains responsive", - ); - console.log("\nMaximum allowed limit: 100 records per request"); - console.log( - "This prevents malicious clients from causing database performance issues.", - ); + console.log('=== Security Impact ==='); + console.log('Before fix: limit=1000000 could trigger full table scan → DB crash'); + console.log('After fix: limit=1000000 is capped to 100 → DB remains responsive'); + console.log('\nMaximum allowed limit: 100 records per request'); + console.log('This prevents malicious clients from causing database performance issues.'); } // Run demonstration diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index 489cd1ad..98bca946 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -1,5 +1,5 @@ -import winston from "winston"; -import { getRequestId } from "./requestContext.js"; +import winston from 'winston'; +import { getRequestId } from './requestContext.js'; const levels = { error: 0, @@ -25,31 +25,30 @@ const level = () => { }; const colors = { - error: "red", - warn: "yellow", - info: "green", - http: "magenta", - debug: "grey", + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'grey', }; winston.addColors(colors); /** Dev: human-readable with colors and optional metadata */ const devFormat = winston.format.combine( - winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.colorize({ all: true }), winston.format.errors({ stack: true }), winston.format.printf(({ level, message, timestamp, stack, ...meta }) => { - const metaStr = - Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ""; - const stackStr = stack ? `\n${stack}` : ""; + const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''; + const stackStr = stack ? `\n${stack}` : ''; return `${timestamp} ${level}: ${message}${metaStr}${stackStr}`; }), ); /** Production: JSON for parsing and querying */ const productionFormat = winston.format.combine( - winston.format.timestamp({ format: "iso" }), + winston.format.timestamp({ format: 'iso' }), winston.format.errors({ stack: true }), winston.format.json(), ); @@ -62,7 +61,7 @@ const withRequestId = winston.format((info) => { return info; }); -const isProduction = process.env.NODE_ENV === "production"; +const isProduction = process.env.NODE_ENV === 'production'; const transports: winston.transport[] = [ new winston.transports.Console({ @@ -94,12 +93,9 @@ const withContext = (context: LogContext = {}) => { if (context.loanId) baseMeta.loanId = context.loanId; return { - info: (message: string, meta?: any) => - logger.info(message, { ...baseMeta, ...meta }), - warn: (message: string, meta?: any) => - logger.warn(message, { ...baseMeta, ...meta }), - error: (message: string, meta?: any) => - logger.error(message, { ...baseMeta, ...meta }), + info: (message: string, meta?: any) => logger.info(message, { ...baseMeta, ...meta }), + warn: (message: string, meta?: any) => logger.warn(message, { ...baseMeta, ...meta }), + error: (message: string, meta?: any) => logger.error(message, { ...baseMeta, ...meta }), }; }; diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts index 680fc6cc..1e5515bb 100644 --- a/backend/src/utils/pagination.ts +++ b/backend/src/utils/pagination.ts @@ -1,4 +1,4 @@ -import type { Request } from "express"; +import type { Request } from 'express'; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; @@ -23,18 +23,18 @@ export interface CursorPaginationParams { export interface SortConfig { field: string; - direction: "ASC" | "DESC"; + direction: 'ASC' | 'DESC'; } export function parseQueryParams(req: Request): PaginationParams { const limit = parsePositiveInteger(req.query.limit, DEFAULT_LIMIT, MAX_LIMIT); const offset = parsePositiveInteger(req.query.offset, 0); const sort = - typeof req.query.sort === "string" && req.query.sort.trim().length > 0 + typeof req.query.sort === 'string' && req.query.sort.trim().length > 0 ? req.query.sort.trim() : null; const status = - typeof req.query.status === "string" && req.query.status.trim().length > 0 + typeof req.query.status === 'string' && req.query.status.trim().length > 0 ? req.query.status.trim() : null; @@ -51,15 +51,15 @@ export function parseQueryParams(req: Request): PaginationParams { export function parseCursorQueryParams(req: Request): CursorPaginationParams { const limit = parsePositiveInteger(req.query.limit, DEFAULT_LIMIT, MAX_LIMIT); const cursor = - typeof req.query.cursor === "string" && req.query.cursor.trim().length > 0 + typeof req.query.cursor === 'string' && req.query.cursor.trim().length > 0 ? req.query.cursor.trim() : null; const sort = - typeof req.query.sort === "string" && req.query.sort.trim().length > 0 + typeof req.query.sort === 'string' && req.query.sort.trim().length > 0 ? req.query.sort.trim() : null; const status = - typeof req.query.status === "string" && req.query.status.trim().length > 0 + typeof req.query.status === 'string' && req.query.status.trim().length > 0 ? req.query.status.trim() : null; @@ -77,20 +77,20 @@ export function getSortConfig( sort: string | null, allowedFields: readonly string[], defaultField: string, - defaultDirection: "ASC" | "DESC", + defaultDirection: 'ASC' | 'DESC', ): SortConfig { if (!sort) { return { field: defaultField, direction: defaultDirection }; } - const requestedField = sort.replace(/^-/, ""); + const requestedField = sort.replace(/^-/, ''); if (!allowedFields.includes(requestedField)) { return { field: defaultField, direction: defaultDirection }; } return { field: requestedField, - direction: sort.startsWith("-") ? "DESC" : "ASC", + direction: sort.startsWith('-') ? 'DESC' : 'ASC', }; } @@ -137,12 +137,8 @@ export function createCursorPaginatedResponse( }; } -function parsePositiveInteger( - value: unknown, - fallback: number, - max?: number, -): number { - if (typeof value !== "string") { +function parsePositiveInteger(value: unknown, fallback: number, max?: number): number { + if (typeof value !== 'string') { return fallback; } @@ -159,11 +155,11 @@ function parsePositiveInteger( } function parseDateRange(value: unknown): { start: Date; end: Date } | null { - if (typeof value !== "string") { + if (typeof value !== 'string') { return null; } - const [startRaw, endRaw] = value.split(",").map((part) => part?.trim()); + const [startRaw, endRaw] = value.split(',').map((part) => part?.trim()); if (!startRaw || !endRaw) { return null; } @@ -179,11 +175,11 @@ function parseDateRange(value: unknown): { start: Date; end: Date } | null { } function parseAmountRange(value: unknown): { min: number; max: number } | null { - if (typeof value !== "string") { + if (typeof value !== 'string') { return null; } - const [minRaw, maxRaw] = value.split(",").map((part) => part?.trim()); + const [minRaw, maxRaw] = value.split(',').map((part) => part?.trim()); if (!minRaw || !maxRaw) { return null; } diff --git a/backend/src/utils/queryHelpers.ts b/backend/src/utils/queryHelpers.ts index e15f061a..3e37739e 100644 --- a/backend/src/utils/queryHelpers.ts +++ b/backend/src/utils/queryHelpers.ts @@ -1,4 +1,4 @@ -import type { Request } from "express"; +import type { Request } from 'express'; const MAX_LIMIT = 100; const DEFAULT_LIMIT = 20; @@ -10,17 +10,10 @@ const DEFAULT_LIMIT = 20; * @param defaultLimit - Default limit to use if not provided (default: 20) * @returns Effective limit that's capped at MAX_LIMIT (100) */ -export function parseCappedLimit( - req: Request, - defaultLimit: number = DEFAULT_LIMIT, -): number { +export function parseCappedLimit(req: Request, defaultLimit: number = DEFAULT_LIMIT): number { const rawLimit = Number(req.query.limit); - if ( - !Number.isFinite(rawLimit) || - rawLimit <= 0 || - rawLimit !== Math.floor(rawLimit) - ) { + if (!Number.isFinite(rawLimit) || rawLimit <= 0 || rawLimit !== Math.floor(rawLimit)) { return defaultLimit; } diff --git a/backend/src/utils/requestContext.ts b/backend/src/utils/requestContext.ts index a46c4591..cd320e1b 100644 --- a/backend/src/utils/requestContext.ts +++ b/backend/src/utils/requestContext.ts @@ -1,5 +1,5 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; interface RequestContext { requestId: string; @@ -9,10 +9,7 @@ const requestContextStorage = new AsyncLocalStorage(); export const createRequestId = (): string => randomUUID(); -export const runWithRequestContext = ( - requestId: string, - callback: () => T, -): T => { +export const runWithRequestContext = (requestId: string, callback: () => T): T => { return requestContextStorage.run({ requestId }, callback); }; diff --git a/backend/src/utils/stellar.ts b/backend/src/utils/stellar.ts index 58d6f68c..fd464d45 100644 --- a/backend/src/utils/stellar.ts +++ b/backend/src/utils/stellar.ts @@ -5,7 +5,7 @@ */ const STELLAR_EXPLORER_URL = - process.env.STELLAR_EXPLORER_URL ?? "https://stellar.expert/explorer/testnet"; + process.env.STELLAR_EXPLORER_URL ?? 'https://stellar.expert/explorer/testnet'; /** * Get Stellar Explorer URL for a transaction hash @@ -29,9 +29,9 @@ export function getAccountUrl(address: string): string { * @returns True if valid, false otherwise */ export function isValidStellarAddress(address: unknown): address is string { - if (!address || typeof address !== "string") return false; + if (!address || typeof address !== 'string') return false; // Stellar public keys are exactly 56 characters, start with 'G' - if (address.length !== 56 || !address.startsWith("G")) return false; + if (address.length !== 56 || !address.startsWith('G')) return false; // Check if it's valid base32 (only contains A-Z and 2-7) return /^G[A-Z2-7]{55}$/.test(address); } @@ -41,7 +41,7 @@ export function isValidStellarAddress(address: unknown): address is string { */ export function assertValidStellarAddress( address: unknown, - message: string = "Invalid Stellar address", + message: string = 'Invalid Stellar address', ): asserts address is string { if (!isValidStellarAddress(address)) { throw new Error(message); diff --git a/backend/test_results.json b/backend/test_results.json index a7280aae..a6baba9b 100644 --- a/backend/test_results.json +++ b/backend/test_results.json @@ -58,10 +58,7 @@ { "assertionResults": [ { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "UserProfileService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "UserProfileService"], "duration": null, "failing": false, "failureDetails": [], @@ -76,10 +73,7 @@ "title": "should create a user profile" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "UserProfileService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "UserProfileService"], "duration": null, "failing": false, "failureDetails": [], @@ -94,10 +88,7 @@ "title": "should find profile by public key" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "UserProfileService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "UserProfileService"], "duration": null, "failing": false, "failureDetails": [], @@ -112,10 +103,7 @@ "title": "should update a user profile" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "UserProfileService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "UserProfileService"], "duration": null, "failing": false, "failureDetails": [], @@ -130,10 +118,7 @@ "title": "should upsert a user profile" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "UserProfileService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "UserProfileService"], "duration": null, "failing": false, "failureDetails": [], @@ -148,10 +133,7 @@ "title": "should delete a user profile" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "LoanHistoryService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "LoanHistoryService"], "duration": null, "failing": false, "failureDetails": [], @@ -166,10 +148,7 @@ "title": "should create a loan history record" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "LoanHistoryService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "LoanHistoryService"], "duration": null, "failing": false, "failureDetails": [], @@ -184,10 +163,7 @@ "title": "should find loans by borrower" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "LoanHistoryService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "LoanHistoryService"], "duration": null, "failing": false, "failureDetails": [], @@ -202,10 +178,7 @@ "title": "should update loan status" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "IndexedEventsService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "IndexedEventsService"], "duration": null, "failing": false, "failureDetails": [], @@ -220,10 +193,7 @@ "title": "should create an indexed event" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "IndexedEventsService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "IndexedEventsService"], "duration": null, "failing": false, "failureDetails": [], @@ -238,10 +208,7 @@ "title": "should find unprocessed events" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "IndexedEventsService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "IndexedEventsService"], "duration": null, "failing": false, "failureDetails": [], @@ -256,10 +223,7 @@ "title": "should mark event as processed" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "IndexedEventsService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "IndexedEventsService"], "duration": null, "failing": false, "failureDetails": [], @@ -274,10 +238,7 @@ "title": "should find events by transaction hash" }, { - "ancestorTitles": [ - "Database Services (skipped: no database)", - "DatabaseService" - ], + "ancestorTitles": ["Database Services (skipped: no database)", "DatabaseService"], "duration": null, "failing": false, "failureDetails": [], @@ -1163,10 +1124,7 @@ "title": "should return 404 for unknown POST routes with error code" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Zod validation errors" - ], + "ancestorTitles": ["Centralized Error Handling", "Zod validation errors"], "duration": 33, "failing": false, "failureDetails": [], @@ -1181,10 +1139,7 @@ "title": "should return 400 with validation failed message and error code" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Zod validation errors" - ], + "ancestorTitles": ["Centralized Error Handling", "Zod validation errors"], "duration": 24, "failing": false, "failureDetails": [], @@ -1199,10 +1154,7 @@ "title": "should include field and message in each validation error detail" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Response structure consistency" - ], + "ancestorTitles": ["Centralized Error Handling", "Response structure consistency"], "duration": 21, "failing": false, "failureDetails": [], @@ -1217,10 +1169,7 @@ "title": "should always include success and error fields in error responses" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Response structure consistency" - ], + "ancestorTitles": ["Centralized Error Handling", "Response structure consistency"], "duration": 54, "failing": false, "failureDetails": [], @@ -1235,10 +1184,7 @@ "title": "should not expose stack traces in production-like responses" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Response structure consistency" - ], + "ancestorTitles": ["Centralized Error Handling", "Response structure consistency"], "duration": 211, "failing": false, "failureDetails": [], @@ -1253,10 +1199,7 @@ "title": "should expose stack traces only with explicit development opt-in" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Specific error scenarios (Diagnostic)" - ], + "ancestorTitles": ["Centralized Error Handling", "Specific error scenarios (Diagnostic)"], "duration": 46, "failing": false, "failureDetails": [], @@ -1271,10 +1214,7 @@ "title": "should handle operational AppErrors (400 Bad Request) with error code" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Specific error scenarios (Diagnostic)" - ], + "ancestorTitles": ["Centralized Error Handling", "Specific error scenarios (Diagnostic)"], "duration": 73, "failing": false, "failureDetails": [], @@ -1289,10 +1229,7 @@ "title": "should handle internal AppErrors (500 Internal Server Error) with error code" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Specific error scenarios (Diagnostic)" - ], + "ancestorTitles": ["Centralized Error Handling", "Specific error scenarios (Diagnostic)"], "duration": 37, "failing": false, "failureDetails": [], @@ -1307,10 +1244,7 @@ "title": "should handle unexpected exceptions (500 Internal Server Error) with error code" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Specific error scenarios (Diagnostic)" - ], + "ancestorTitles": ["Centralized Error Handling", "Specific error scenarios (Diagnostic)"], "duration": 77, "failing": false, "failureDetails": [], @@ -1325,10 +1259,7 @@ "title": "should catch async exceptions via asyncHandler middleware with error code" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Authentication error codes" - ], + "ancestorTitles": ["Centralized Error Handling", "Authentication error codes"], "duration": 46, "failing": false, "failureDetails": [], @@ -1343,10 +1274,7 @@ "title": "should return VALIDATION_ERROR error code for missing public key (Zod validation)" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Authentication error codes" - ], + "ancestorTitles": ["Centralized Error Handling", "Authentication error codes"], "duration": 28, "failing": false, "failureDetails": [], @@ -1361,10 +1289,7 @@ "title": "should return INVALID_PUBLIC_KEY error code for invalid key format" }, { - "ancestorTitles": [ - "Centralized Error Handling", - "Authentication error codes" - ], + "ancestorTitles": ["Centralized Error Handling", "Authentication error codes"], "duration": 36, "failing": false, "failureDetails": [], @@ -1819,10 +1744,7 @@ { "assertionResults": [ { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 9, "failing": false, "failureDetails": [], @@ -1837,10 +1759,7 @@ "title": "should allow request within rate limit" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 3, "failing": false, "failureDetails": [], @@ -1855,10 +1774,7 @@ "title": "should block request exceeding rate limit" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 2, "failing": false, "failureDetails": [], @@ -1873,10 +1789,7 @@ "title": "should use custom identifier function" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 4, "failing": false, "failureDetails": [], @@ -1891,10 +1804,7 @@ "title": "should use custom configuration" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 3, "failing": false, "failureDetails": [], @@ -1909,10 +1819,7 @@ "title": "should skip rate limiting when condition is met" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 3, "failing": false, "failureDetails": [], @@ -1927,10 +1834,7 @@ "title": "should fail open when rate limit service fails" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 5, "failing": false, "failureDetails": [], @@ -1945,10 +1849,7 @@ "title": "should handle missing userId gracefully" }, { - "ancestorTitles": [ - "Rate Limit Middleware", - "createRateLimitMiddleware" - ], + "ancestorTitles": ["Rate Limit Middleware", "createRateLimitMiddleware"], "duration": 40, "failing": false, "failureDetails": [], diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 2722cd09..07a10138 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -70,27 +70,27 @@ This document lists every environment variable used by the RemitLend platform. E ## Frontend (`frontend/`) -| Variable | Dev | Staging | Prod | Default | Description | Source | -|---|---|---|---|---|---|---| -| `NEXT_PUBLIC_API_URL` | ✓ | ✓ | ✓ | `http://localhost:3001` | Backend API base URL | `frontend/src/app/hooks/useApi.ts` | -| `NEXT_PUBLIC_SENTRY_DSN` | — | ✓ | ✓ | — | Sentry DSN for frontend error tracking | `frontend/src/sentry.client.config.ts` | -| `SENTRY_DSN` | — | ✓ | ✓ | — | Sentry DSN server-side | `frontend/src/sentry.server.config.ts` | -| `SENTRY_ORG` | — | ✓ | ✓ | — | Sentry organization slug | `frontend/sentry.client.config.ts` | -| `SENTRY_PROJECT` | — | ✓ | ✓ | — | Sentry project slug | `frontend/sentry.client.config.ts` | -| `SENTRY_AUTH_TOKEN` | — | ✓ | ✓ | — | Sentry auth token for source maps | `frontend/next.config.ts` | -| `NODE_ENV` | ✓ | ✓ | ✓ | `development` | Node environment (`development`, `test`, `production`) | `next.config.ts` | -| `NEXT_PUBLIC_STELLAR_EXPLORER_URL` | ✓ | ✓ | ✓ | `https://stellar.expert/explorer/testnet` | Stellar explorer base URL for transaction links | `frontend/src/components/ui/TxHashLink.tsx` | +| Variable | Dev | Staging | Prod | Default | Description | Source | +| ---------------------------------- | --- | ------- | ---- | ----------------------------------------- | ------------------------------------------------------ | ------------------------------------------- | +| `NEXT_PUBLIC_API_URL` | ✓ | ✓ | ✓ | `http://localhost:3001` | Backend API base URL | `frontend/src/app/hooks/useApi.ts` | +| `NEXT_PUBLIC_SENTRY_DSN` | — | ✓ | ✓ | — | Sentry DSN for frontend error tracking | `frontend/src/sentry.client.config.ts` | +| `SENTRY_DSN` | — | ✓ | ✓ | — | Sentry DSN server-side | `frontend/src/sentry.server.config.ts` | +| `SENTRY_ORG` | — | ✓ | ✓ | — | Sentry organization slug | `frontend/sentry.client.config.ts` | +| `SENTRY_PROJECT` | — | ✓ | ✓ | — | Sentry project slug | `frontend/sentry.client.config.ts` | +| `SENTRY_AUTH_TOKEN` | — | ✓ | ✓ | — | Sentry auth token for source maps | `frontend/next.config.ts` | +| `NODE_ENV` | ✓ | ✓ | ✓ | `development` | Node environment (`development`, `test`, `production`) | `next.config.ts` | +| `NEXT_PUBLIC_STELLAR_EXPLORER_URL` | ✓ | ✓ | ✓ | `https://stellar.expert/explorer/testnet` | Stellar explorer base URL for transaction links | `frontend/src/components/ui/TxHashLink.tsx` | --- ## Contracts / Scripts (`contracts/`, `scripts/`) -| Variable | Dev | Staging | Prod | Default | Description | Source | -|---|---|---|---|---|---|---| -| `SOROBAN_RPC_URL` | ✓ | ✓ | ✓ | `https://soroban-testnet.stellar.org` | RPC URL for contract deployment | `scripts/deploy.ts` | -| `SOROBAN_NETWORK_PASSPHRASE` | ✓ | ✓ | ✓ | `Test SDF Network ; September 2015` | Network passphrase for contract operations | `scripts/deploy.ts` | -| `SOROBAN_ACCOUNT` | ✓ | ✓ | ✓ | — | Deployer account secret key | `scripts/deploy.ts` | -| `DEPLOY_CONFIG_PATH` | — | ✓ | ✓ | `scripts/deploy-config.json` | Path to deploy configuration | `scripts/deploy.ts` | +| Variable | Dev | Staging | Prod | Default | Description | Source | +| ---------------------------- | --- | ------- | ---- | ------------------------------------- | ------------------------------------------ | ------------------- | +| `SOROBAN_RPC_URL` | ✓ | ✓ | ✓ | `https://soroban-testnet.stellar.org` | RPC URL for contract deployment | `scripts/deploy.ts` | +| `SOROBAN_NETWORK_PASSPHRASE` | ✓ | ✓ | ✓ | `Test SDF Network ; September 2015` | Network passphrase for contract operations | `scripts/deploy.ts` | +| `SOROBAN_ACCOUNT` | ✓ | ✓ | ✓ | — | Deployer account secret key | `scripts/deploy.ts` | +| `DEPLOY_CONFIG_PATH` | — | ✓ | ✓ | `scripts/deploy-config.json` | Path to deploy configuration | `scripts/deploy.ts` | --- diff --git a/frontend/src/app/hooks/useApi.ts b/frontend/src/app/hooks/useApi.ts index aae72a18..5f1b7b19 100644 --- a/frontend/src/app/hooks/useApi.ts +++ b/frontend/src/app/hooks/useApi.ts @@ -1057,71 +1057,115 @@ export function useCreditScore( let cancelled = false; let retryDelay = 1_000; - let eventSource: EventSource | null = null; + let abortControllerRef: AbortController | null = null; let retryTimeout: ReturnType | null = null; - const connect = () => { + const connect = async () => { if (cancelled) { return; } - const url = `${API_URL}/api/events/stream?borrower=${encodeURIComponent(walletAddress)}`; - const es = new EventSource(url, { withCredentials: true }); - eventSource = es; + if (abortControllerRef) { + abortControllerRef.abort(); + } - es.onopen = () => { - retryDelay = 1_000; - }; - - es.onmessage = (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as { - type?: string; - borrower?: string; - eventType?: string; - }; + const controller = new AbortController(); + abortControllerRef = controller; - if (payload.type === "init") { - return; - } + try { + const url = `${API_URL}/api/events/stream?borrower=${encodeURIComponent(walletAddress)}`; + const headers: Record = { + Accept: "text/event-stream", + }; - const scoreChangingEvent = - payload.eventType === "LoanRepaid" || payload.eventType === "LoanDefaulted"; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } - if (payload.borrower === walletAddress && scoreChangingEvent) { - const currentScore = queryClient.getQueryData(["creditScore", userId]); + const response = await fetch(url, { + headers, + signal: controller.signal, + }); - setPreviousScoreState({ - walletAddress, - previousScore: currentScore, - }); + if (!response.ok) { + throw new Error(`SSE connection failed: ${response.status}`); + } - queryClient.invalidateQueries({ - queryKey: ["creditScore", userId], - }); - } - } catch { - // Ignore malformed SSE payloads. + if (!response.body) { + throw new Error("Response body is null"); } - }; - es.onerror = () => { - es.close(); - eventSource = null; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + retryDelay = 1_000; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() || ""; + + for (const part of parts) { + const lines = part.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + const dataStr = line.slice(6); + try { + const payload = JSON.parse(dataStr) as { + type?: string; + borrower?: string; + eventType?: string; + }; + + if (payload.type === "init") { + continue; + } + + const scoreChangingEvent = + payload.eventType === "LoanRepaid" || payload.eventType === "LoanDefaulted"; + + if (payload.borrower === walletAddress && scoreChangingEvent) { + const currentScore = queryClient.getQueryData(["creditScore", userId]); + + setPreviousScoreState({ + walletAddress, + previousScore: currentScore, + }); + + queryClient.invalidateQueries({ + queryKey: ["creditScore", userId], + }); + } + } catch { + // Ignore malformed SSE payloads. + } + } + } + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return; + } if (!cancelled) { const delay = Math.min(retryDelay, 30_000); retryDelay = Math.min(retryDelay * 2, 30_000); - retryTimeout = setTimeout(connect, delay); + retryTimeout = setTimeout(() => void connect(), delay); } - }; + } }; - connect(); + void connect(); return () => { cancelled = true; - eventSource?.close(); + if (abortControllerRef) { + abortControllerRef.abort(); + } if (retryTimeout) { clearTimeout(retryTimeout); }