diff --git a/migrations/002_add_interest_rate_to_credit_lines.sql b/migrations/002_add_interest_rate_to_credit_lines.sql new file mode 100644 index 0000000..525cf61 --- /dev/null +++ b/migrations/002_add_interest_rate_to_credit_lines.sql @@ -0,0 +1,10 @@ +-- Add interest_rate_bps column to credit_lines table +-- This field stores the interest rate in basis points (e.g., 500 = 5%) + +ALTER TABLE credit_lines +ADD COLUMN interest_rate_bps INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN credit_lines.interest_rate_bps IS 'Interest rate in basis points (e.g., 500 = 5%)'; + +-- Add index for queries filtering by interest rate +CREATE INDEX credit_lines_interest_rate_bps_idx ON credit_lines (interest_rate_bps); \ No newline at end of file diff --git a/src/__test__/creditRoute.test.ts b/src/__test__/creditRoute.test.ts index 9373402..5a473fa 100644 --- a/src/__test__/creditRoute.test.ts +++ b/src/__test__/creditRoute.test.ts @@ -17,7 +17,7 @@ vi.mock("../middleware/adminAuth.js", () => ({ import creditRouter from "../routes/credit.js"; import { adminAuth } from "../middleware/adminAuth.js"; -import { afterEach, beforeEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; const mockAdminAuth = vi.mocked(adminAuth); @@ -37,7 +37,7 @@ function allowAdmin() { } function denyAdmin() { - mockAdminAuth.mockImplementation((_req, res: any, _next) => { + mockAdminAuth.mockImplementation((_req, res: Response, _next) => { res.status(401).json({ error: "Unauthorized: valid X-Admin-Api-Key header is required." }); }); } diff --git a/src/__test__/horizonListener.test.ts b/src/__test__/horizonListener.test.ts index 22d6d17..415710c 100644 --- a/src/__test__/horizonListener.test.ts +++ b/src/__test__/horizonListener.test.ts @@ -392,6 +392,13 @@ describe("onEvent() / clearEventHandlers()", () => { // pollOnce() // --------------------------------------------------------------------------- +const baseConfig: HorizonListenerConfig = { + horizonUrl: "https://horizon-testnet.stellar.org", + contractIds: [], + pollIntervalMs: 5000, + startLedger: "latest", +}; + describe("pollOnce()", () => { it("completes without throwing when contractIds is empty", async () => { diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 3db525b..d8d24b2 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, afterEach } from 'vitest'; import request from 'supertest'; +import type { Server } from 'http'; // We need to test the actual index.ts file, so let's create a separate test describe('Main Application', () => { - let server: any; + let server: Server | undefined; afterEach(() => { if (server) { diff --git a/src/container/Container.ts b/src/container/Container.ts index a60c20c..cb48cbf 100644 --- a/src/container/Container.ts +++ b/src/container/Container.ts @@ -4,6 +4,7 @@ import { type TransactionRepository } from "../repositories/interfaces/Transacti import { InMemoryCreditLineRepository } from "../repositories/memory/InMemoryCreditLineRepository.js"; import { InMemoryRiskEvaluationRepository } from "../repositories/memory/InMemoryRiskEvaluationRepository.js"; import { InMemoryTransactionRepository } from "../repositories/memory/InMemoryTransactionRepository.js"; +import { PostgresCreditLineRepository } from "../repositories/postgres/PostgresCreditLineRepository.js"; import { CreditLineService } from "../services/CreditLineService.js"; import { RiskEvaluationService } from "../services/RiskEvaluationService.js"; import { ReconciliationService } from "../services/reconciliationService.js"; @@ -14,10 +15,13 @@ import { defaultJobQueue } from "../services/jobQueue.js"; export class Container { private static instance: Container; + // Database client + private _dbClient?: DbClient; + // Repositories - private _creditLineRepository: CreditLineRepository; - private _riskEvaluationRepository: RiskEvaluationRepository; - private _transactionRepository: TransactionRepository; + private _creditLineRepository!: CreditLineRepository; + private _riskEvaluationRepository!: RiskEvaluationRepository; + private _transactionRepository!: TransactionRepository; // Services private _creditLineService: CreditLineService; @@ -26,10 +30,8 @@ export class Container { private _reconciliationWorker: ReconciliationWorker; private constructor() { - // Initialize repositories (in-memory implementations for now) - this._creditLineRepository = new InMemoryCreditLineRepository(); - this._riskEvaluationRepository = new InMemoryRiskEvaluationRepository(); - this._transactionRepository = new InMemoryTransactionRepository(); + // Initialize repositories based on environment + this.initializeRepositories(); // Initialize services this._creditLineService = new CreditLineService(this._creditLineRepository); @@ -52,6 +54,24 @@ export class Container { ); } + private initializeRepositories(): void { + const useDatabase = process.env.DATABASE_URL && process.env.NODE_ENV !== 'test'; + + if (useDatabase) { + // Use PostgreSQL repositories + this._dbClient = getConnection(); + this._creditLineRepository = new PostgresCreditLineRepository(this._dbClient); + // TODO: Implement PostgreSQL versions of other repositories + this._riskEvaluationRepository = new InMemoryRiskEvaluationRepository(); + this._transactionRepository = new InMemoryTransactionRepository(); + } else { + // Use in-memory repositories (for development/testing) + this._creditLineRepository = new InMemoryCreditLineRepository(); + this._riskEvaluationRepository = new InMemoryRiskEvaluationRepository(); + this._transactionRepository = new InMemoryTransactionRepository(); + } + } + public static getInstance(): Container { if (!Container.instance) { Container.instance = new Container(); diff --git a/src/container/__tests__/Container.postgres.test.ts b/src/container/__tests__/Container.postgres.test.ts new file mode 100644 index 0000000..6ab528c --- /dev/null +++ b/src/container/__tests__/Container.postgres.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Container } from '../Container.js'; +import { InMemoryCreditLineRepository } from '../../repositories/memory/InMemoryCreditLineRepository.js'; +import { PostgresCreditLineRepository } from '../../repositories/postgres/PostgresCreditLineRepository.js'; + +// Mock the database client +vi.mock('../../db/client.js', () => ({ + getConnection: vi.fn(() => ({ + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockResolvedValue(undefined), + })), +})); + +describe('Container - PostgreSQL Integration', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + // Reset the singleton instance + (Container as unknown as { instance: Container | undefined }).instance = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should use in-memory repositories when DATABASE_URL is not set', () => { + delete process.env.DATABASE_URL; + + const container = Container.getInstance(); + const repository = container.creditLineRepository; + + expect(repository).toBeInstanceOf(InMemoryCreditLineRepository); + }); + + it('should use in-memory repositories in test environment even with DATABASE_URL', () => { + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + process.env.NODE_ENV = 'test'; + + const container = Container.getInstance(); + const repository = container.creditLineRepository; + + expect(repository).toBeInstanceOf(InMemoryCreditLineRepository); + }); + + it('should use PostgreSQL repositories when DATABASE_URL is set and not in test environment', () => { + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + process.env.NODE_ENV = 'development'; + + const container = Container.getInstance(); + const repository = container.creditLineRepository; + + expect(repository).toBeInstanceOf(PostgresCreditLineRepository); + }); + + it('should use PostgreSQL repositories in production environment', () => { + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + process.env.NODE_ENV = 'production'; + + const container = Container.getInstance(); + const repository = container.creditLineRepository; + + expect(repository).toBeInstanceOf(PostgresCreditLineRepository); + }); + + it('should properly shutdown database connections', async () => { + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + process.env.NODE_ENV = 'development'; + + const container = Container.getInstance(); + + // Mock console.log to avoid test output noise + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await container.shutdown(); + + expect(consoleSpy).toHaveBeenCalledWith('[Container] Database connection closed.'); + expect(consoleSpy).toHaveBeenCalledWith('[Container] All services shut down.'); + + consoleSpy.mockRestore(); + }); + + it('should handle database connection close errors gracefully', async () => { + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + process.env.NODE_ENV = 'development'; + + // Mock getConnection to return a client that throws on end() + const { getConnection } = await import('../../db/client.js'); + vi.mocked(getConnection).mockReturnValue({ + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockRejectedValue(new Error('Connection close failed')), + }); + + const container = Container.getInstance(); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await container.shutdown(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[Container] Error closing database connection:', + expect.any(Error) + ); + expect(consoleLogSpy).toHaveBeenCalledWith('[Container] All services shut down.'); + + consoleSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index 58c5958..c366b53 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -82,7 +82,7 @@ describe('applyMigration', () => { await applyMigration(client, migrationsDir, '001_initial_schema.sql'); expect(client.query).toHaveBeenCalled(); const insertCall = vi.mocked(client.query).mock.calls.find( - (c) => typeof c[0] === 'string' && c[0].includes('INSERT INTO schema_migrations') + (c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO schema_migrations') ); expect(insertCall).toBeDefined(); expect(insertCall![1]).toEqual(['001_initial_schema']); @@ -94,7 +94,7 @@ describe('runPendingMigrations', () => { const client = createMockClient(); vi.mocked(client.query) .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ version: '001_initial_schema' }] }); + .mockResolvedValueOnce({ rows: [{ version: '001_initial_schema' }, { version: '002_add_interest_rate_to_credit_lines' }] }); const migrationsDir = await import('path').then((p) => p.join(process.cwd(), 'migrations') ); @@ -112,7 +112,21 @@ describe('runPendingMigrations', () => { ); const run = await runPendingMigrations(client, migrationsDir); expect(run).toContain('001_initial_schema'); - expect(run.length).toBeGreaterThanOrEqual(1); + expect(run).toContain('002_add_interest_rate_to_credit_lines'); + expect(run.length).toBeGreaterThanOrEqual(2); + }); + + it('applies only new migrations when some are already applied', async () => { + const client = createMockClient(); + vi.mocked(client.query) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ version: '001_initial_schema' }] }); + const migrationsDir = await import('path').then((p) => + p.join(process.cwd(), 'migrations') + ); + const run = await runPendingMigrations(client, migrationsDir); + expect(run).toContain('002_add_interest_rate_to_credit_lines'); + expect(run).not.toContain('001_initial_schema'); }); }); diff --git a/src/repositories/postgres/PostgresCreditLineRepository.ts b/src/repositories/postgres/PostgresCreditLineRepository.ts new file mode 100644 index 0000000..bef6dad --- /dev/null +++ b/src/repositories/postgres/PostgresCreditLineRepository.ts @@ -0,0 +1,310 @@ +import type { CreditLine, CreateCreditLineRequest, UpdateCreditLineRequest, CreditLineStatus } from '../../models/CreditLine.js'; +import type { CreditLineRepository } from '../interfaces/CreditLineRepository.js'; +import type { DbClient } from '../../db/client.js'; + +export class PostgresCreditLineRepository implements CreditLineRepository { + constructor(private client: DbClient) {} + + async create(request: CreateCreditLineRequest): Promise { + // First, ensure borrower exists or create it + const borrowerId = await this.ensureBorrower(request.walletAddress); + + const query = ` + INSERT INTO credit_lines (borrower_id, credit_limit, currency, status, interest_rate_bps) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, borrower_id, credit_limit, currency, status, interest_rate_bps, created_at, updated_at + `; + + const values = [ + borrowerId, + request.creditLimit, + 'USDC', // Default currency - could be made configurable + 'active', // Default status + request.interestRateBps + ]; + + const result = await this.client.query(query, values); + const row = result.rows[0] as { + id: string; + borrower_id: string; + credit_limit: string; + currency: string; + status: string; + interest_rate_bps: number; + created_at: Date; + updated_at: Date; + }; + + // Get wallet address for the response + const walletAddress = await this.getWalletAddress(borrowerId); + + return { + id: row.id, + walletAddress, + creditLimit: row.credit_limit, + availableCredit: row.credit_limit, // Initially full credit available + interestRateBps: row.interest_rate_bps, + status: row.status as CreditLineStatus, + createdAt: row.created_at, + updatedAt: row.updated_at + }; + } + + async findById(id: string): Promise { + const query = ` + SELECT + cl.id, + cl.credit_limit, + cl.currency, + cl.status, + cl.interest_rate_bps, + cl.created_at, + cl.updated_at, + b.wallet_address + FROM credit_lines cl + JOIN borrowers b ON cl.borrower_id = b.id + WHERE cl.id = $1 + `; + + const result = await this.client.query(query, [id]); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0] as { + id: string; + credit_limit: string; + currency: string; + status: string; + interest_rate_bps: number; + created_at: Date; + updated_at: Date; + wallet_address: string; + }; + + // Calculate available credit by subtracting total draws + const availableCredit = await this.calculateAvailableCredit(id, row.credit_limit); + + return { + id: row.id, + walletAddress: row.wallet_address, + creditLimit: row.credit_limit, + availableCredit, + interestRateBps: row.interest_rate_bps, + status: row.status as CreditLineStatus, + createdAt: row.created_at, + updatedAt: row.updated_at + }; + } + + async findByWalletAddress(walletAddress: string): Promise { + const query = ` + SELECT + cl.id, + cl.credit_limit, + cl.currency, + cl.status, + cl.interest_rate_bps, + cl.created_at, + cl.updated_at, + b.wallet_address + FROM credit_lines cl + JOIN borrowers b ON cl.borrower_id = b.id + WHERE b.wallet_address = $1 + ORDER BY cl.created_at DESC + `; + + const result = await this.client.query(query, [walletAddress]); + const creditLines: CreditLine[] = []; + + for (const row of result.rows as Array<{ + id: string; + credit_limit: string; + currency: string; + status: string; + interest_rate_bps: number; + created_at: Date; + updated_at: Date; + wallet_address: string; + }>) { + const availableCredit = await this.calculateAvailableCredit(row.id, row.credit_limit); + + creditLines.push({ + id: row.id, + walletAddress: row.wallet_address, + creditLimit: row.credit_limit, + availableCredit, + interestRateBps: row.interest_rate_bps, + status: row.status as CreditLineStatus, + createdAt: row.created_at, + updatedAt: row.updated_at + }); + } + + return creditLines; + } + + async findAll(offset = 0, limit = 100): Promise { + const query = ` + SELECT + cl.id, + cl.credit_limit, + cl.currency, + cl.status, + cl.interest_rate_bps, + cl.created_at, + cl.updated_at, + b.wallet_address + FROM credit_lines cl + JOIN borrowers b ON cl.borrower_id = b.id + ORDER BY cl.created_at DESC + LIMIT $1 OFFSET $2 + `; + + const result = await this.client.query(query, [limit, offset]); + const creditLines: CreditLine[] = []; + + for (const row of result.rows as Array<{ + id: string; + credit_limit: string; + currency: string; + status: string; + interest_rate_bps: number; + created_at: Date; + updated_at: Date; + wallet_address: string; + }>) { + const availableCredit = await this.calculateAvailableCredit(row.id, row.credit_limit); + + creditLines.push({ + id: row.id, + walletAddress: row.wallet_address, + creditLimit: row.credit_limit, + availableCredit, + interestRateBps: row.interest_rate_bps, + status: row.status as CreditLineStatus, + createdAt: row.created_at, + updatedAt: row.updated_at + }); + } + + return creditLines; + } + + async update(id: string, request: UpdateCreditLineRequest): Promise { + const setParts: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (request.creditLimit !== undefined) { + setParts.push(`credit_limit = $${paramIndex++}`); + values.push(request.creditLimit); + } + + if (request.interestRateBps !== undefined) { + setParts.push(`interest_rate_bps = $${paramIndex++}`); + values.push(request.interestRateBps); + } + + if (request.status !== undefined) { + setParts.push(`status = $${paramIndex++}`); + values.push(request.status); + } + + if (setParts.length === 0) { + // No updates requested, return current record + return this.findById(id); + } + + setParts.push(`updated_at = now()`); + values.push(id); // For WHERE clause + + const query = ` + UPDATE credit_lines + SET ${setParts.join(', ')} + WHERE id = $${paramIndex} + RETURNING id + `; + + const result = await this.client.query(query, values); + + if (result.rows.length === 0) { + return null; + } + + // Return updated record + return this.findById(id); + } + + async delete(id: string): Promise { + const query = 'DELETE FROM credit_lines WHERE id = $1'; + const result = await this.client.query(query, [id]); + return (result as unknown as { rowCount: number }).rowCount > 0; + } + + async exists(id: string): Promise { + const query = 'SELECT 1 FROM credit_lines WHERE id = $1'; + const result = await this.client.query(query, [id]); + return result.rows.length > 0; + } + + async count(): Promise { + const query = 'SELECT COUNT(*) as count FROM credit_lines'; + const result = await this.client.query(query); + const row = result.rows[0] as { count: string }; + return parseInt(row.count, 10); + } + + /** + * Ensure borrower exists for the given wallet address, create if not exists. + * Returns the borrower ID. + */ + private async ensureBorrower(walletAddress: string): Promise { + // Try to find existing borrower + const findQuery = 'SELECT id FROM borrowers WHERE wallet_address = $1'; + const findResult = await this.client.query(findQuery, [walletAddress]); + + if (findResult.rows.length > 0) { + const row = findResult.rows[0] as { id: string }; + return row.id; + } + + // Create new borrower + const createQuery = ` + INSERT INTO borrowers (wallet_address) + VALUES ($1) + RETURNING id + `; + const createResult = await this.client.query(createQuery, [walletAddress]); + const row = createResult.rows[0] as { id: string }; + return row.id; + } + + /** + * Get wallet address for a borrower ID. + */ + private async getWalletAddress(borrowerId: string): Promise { + const query = 'SELECT wallet_address FROM borrowers WHERE id = $1'; + const result = await this.client.query(query, [borrowerId]); + + if (result.rows.length === 0) { + throw new Error(`Borrower not found: ${borrowerId}`); + } + + const row = result.rows[0] as { wallet_address: string }; + return row.wallet_address; + } + + /** + * Calculate available credit by subtracting total draws from credit limit. + * For now, returns the full credit limit since we don't have transaction tracking yet. + */ + private async calculateAvailableCredit(creditLineId: string, creditLimit: string): Promise { + // TODO: When transaction repository is implemented, calculate: + // creditLimit - SUM(transactions where type = 'draw' and credit_line_id = creditLineId) + + // For now, return full credit limit + return creditLimit; + } +} \ No newline at end of file diff --git a/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts b/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts new file mode 100644 index 0000000..312bcd9 --- /dev/null +++ b/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DbClient } from '../../../db/client.js'; +import { PostgresCreditLineRepository } from '../PostgresCreditLineRepository.js'; +import { CreditLineStatus } from '../../../models/CreditLine.js'; + +function createMockClient(overrides: Partial = {}): DbClient { + return { + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('PostgresCreditLineRepository', () => { + let repository: PostgresCreditLineRepository; + let mockClient: DbClient; + + beforeEach(() => { + mockClient = createMockClient(); + repository = new PostgresCreditLineRepository(mockClient); + }); + + describe('create', () => { + it('should create a new credit line with new borrower', async () => { + const mockBorrowerId = 'borrower-123'; + const mockCreditLineId = 'credit-line-456'; + const now = new Date(); + + // Mock borrower lookup (not found) + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ rows: [] }) + // Mock borrower creation + .mockResolvedValueOnce({ rows: [{ id: mockBorrowerId }] }) + // Mock credit line creation + .mockResolvedValueOnce({ + rows: [{ + id: mockCreditLineId, + borrower_id: mockBorrowerId, + credit_limit: '10000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 500, + created_at: now, + updated_at: now + }] + }) + // Mock wallet address lookup + .mockResolvedValueOnce({ rows: [{ wallet_address: 'GTEST123' }] }); + + const request = { + walletAddress: 'GTEST123', + creditLimit: '10000.00', + interestRateBps: 500 + }; + + const result = await repository.create(request); + + expect(result).toEqual({ + id: mockCreditLineId, + walletAddress: 'GTEST123', + creditLimit: '10000.00', + availableCredit: '10000.00', + interestRateBps: 500, + status: CreditLineStatus.ACTIVE, + createdAt: now, + updatedAt: now + }); + + expect(mockClient.query).toHaveBeenCalledTimes(4); + }); + + it('should create a credit line with existing borrower', async () => { + const mockBorrowerId = 'borrower-123'; + const mockCreditLineId = 'credit-line-456'; + const now = new Date(); + + // Mock borrower lookup (found) + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ rows: [{ id: mockBorrowerId }] }) + // Mock credit line creation + .mockResolvedValueOnce({ + rows: [{ + id: mockCreditLineId, + borrower_id: mockBorrowerId, + credit_limit: '5000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 750, + created_at: now, + updated_at: now + }] + }) + // Mock wallet address lookup + .mockResolvedValueOnce({ rows: [{ wallet_address: 'GTEST456' }] }); + + const request = { + walletAddress: 'GTEST456', + creditLimit: '5000.00', + interestRateBps: 750 + }; + + const result = await repository.create(request); + + expect(result.interestRateBps).toBe(750); + expect(mockClient.query).toHaveBeenCalledTimes(3); // No borrower creation + }); + }); + + describe('findById', () => { + it('should return credit line when found', async () => { + const mockId = 'credit-line-123'; + const now = new Date(); + + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ + rows: [{ + id: mockId, + credit_limit: '15000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 600, + created_at: now, + updated_at: now, + wallet_address: 'GTEST789' + }] + }); + + const result = await repository.findById(mockId); + + expect(result).toEqual({ + id: mockId, + walletAddress: 'GTEST789', + creditLimit: '15000.00', + availableCredit: '15000.00', // Full credit available initially + interestRateBps: 600, + status: CreditLineStatus.ACTIVE, + createdAt: now, + updatedAt: now + }); + }); + + it('should return null when not found', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [] }); + + const result = await repository.findById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('findByWalletAddress', () => { + it('should return credit lines for wallet address', async () => { + const walletAddress = 'GTEST123'; + const now = new Date(); + + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ + rows: [ + { + id: 'credit-line-1', + credit_limit: '10000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 500, + created_at: now, + updated_at: now, + wallet_address: walletAddress + }, + { + id: 'credit-line-2', + credit_limit: '5000.00', + currency: 'USDC', + status: 'suspended', + interest_rate_bps: 750, + created_at: now, + updated_at: now, + wallet_address: walletAddress + } + ] + }); + + const result = await repository.findByWalletAddress(walletAddress); + + expect(result).toHaveLength(2); + expect(result[0].interestRateBps).toBe(500); + expect(result[1].interestRateBps).toBe(750); + expect(result[1].status).toBe(CreditLineStatus.SUSPENDED); + }); + + it('should return empty array when no credit lines found', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [] }); + + const result = await repository.findByWalletAddress('GNONEXISTENT'); + + expect(result).toEqual([]); + }); + }); + + describe('findAll', () => { + it('should return paginated credit lines', async () => { + const now = new Date(); + + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ + rows: [ + { + id: 'credit-line-1', + credit_limit: '10000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 500, + created_at: now, + updated_at: now, + wallet_address: 'GTEST1' + } + ] + }); + + const result = await repository.findAll(0, 10); + + expect(result).toHaveLength(1); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('LIMIT $1 OFFSET $2'), + [10, 0] + ); + }); + }); + + describe('update', () => { + it('should update credit line fields', async () => { + const creditLineId = 'credit-line-123'; + const now = new Date(); + + // Mock update query + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ rows: [{ id: creditLineId }] }) + // Mock findById for return value + .mockResolvedValueOnce({ + rows: [{ + id: creditLineId, + credit_limit: '20000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 400, + created_at: now, + updated_at: now, + wallet_address: 'GTEST123' + }] + }); + + const updateRequest = { + creditLimit: '20000.00', + interestRateBps: 400 + }; + + const result = await repository.update(creditLineId, updateRequest); + + expect(result?.creditLimit).toBe('20000.00'); + expect(result?.interestRateBps).toBe(400); + + // Verify update query was called with correct parameters + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE credit_lines'), + ['20000.00', 400, creditLineId] + ); + }); + + it('should return null when credit line not found', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [] }); + + const result = await repository.update('nonexistent', { status: CreditLineStatus.CLOSED }); + + expect(result).toBeNull(); + }); + + it('should return current record when no updates provided', async () => { + const creditLineId = 'credit-line-123'; + const now = new Date(); + + // Mock findById call + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ + rows: [{ + id: creditLineId, + credit_limit: '10000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 500, + created_at: now, + updated_at: now, + wallet_address: 'GTEST123' + }] + }); + + const result = await repository.update(creditLineId, {}); + + expect(result?.id).toBe(creditLineId); + expect(mockClient.query).toHaveBeenCalledTimes(1); // Only findById, no update + }); + }); + + describe('delete', () => { + it('should delete credit line and return true', async () => { + const mockResult = { rowCount: 1 }; + vi.mocked(mockClient.query).mockResolvedValueOnce(mockResult as unknown as { rows: unknown[] }); + + const result = await repository.delete('credit-line-123'); + + expect(result).toBe(true); + expect(mockClient.query).toHaveBeenCalledWith( + 'DELETE FROM credit_lines WHERE id = $1', + ['credit-line-123'] + ); + }); + + it('should return false when credit line not found', async () => { + const mockResult = { rowCount: 0 }; + vi.mocked(mockClient.query).mockResolvedValueOnce(mockResult as unknown as { rows: unknown[] }); + + const result = await repository.delete('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('exists', () => { + it('should return true when credit line exists', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [{ '?column?': 1 }] }); + + const result = await repository.exists('credit-line-123'); + + expect(result).toBe(true); + }); + + it('should return false when credit line does not exist', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [] }); + + const result = await repository.exists('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('count', () => { + it('should return total count of credit lines', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [{ count: '42' }] }); + + const result = await repository.count(); + + expect(result).toBe(42); + expect(mockClient.query).toHaveBeenCalledWith('SELECT COUNT(*) as count FROM credit_lines'); + }); + }); + + describe('ensureBorrower', () => { + it('should handle borrower creation error gracefully', async () => { + // Mock borrower lookup (not found) + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ rows: [] }) + // Mock borrower creation failure + .mockRejectedValueOnce(new Error('Database error')); + + const request = { + walletAddress: 'GTEST123', + creditLimit: '10000.00', + interestRateBps: 500 + }; + + await expect(repository.create(request)).rejects.toThrow('Database error'); + }); + }); + + describe('getWalletAddress', () => { + it('should throw error when borrower not found', async () => { + // Mock successful borrower lookup + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ rows: [{ id: 'borrower-123' }] }) + // Mock credit line creation + .mockResolvedValueOnce({ + rows: [{ + id: 'credit-line-456', + borrower_id: 'borrower-123', + credit_limit: '10000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 500, + created_at: new Date(), + updated_at: new Date() + }] + }) + // Mock wallet address lookup failure + .mockResolvedValueOnce({ rows: [] }); + + const request = { + walletAddress: 'GTEST123', + creditLimit: '10000.00', + interestRateBps: 500 + }; + + await expect(repository.create(request)).rejects.toThrow('Borrower not found: borrower-123'); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/__tests__/credit.test.ts b/src/routes/__tests__/credit.test.ts index 9dde1be..f34afd5 100644 --- a/src/routes/__tests__/credit.test.ts +++ b/src/routes/__tests__/credit.test.ts @@ -3,7 +3,7 @@ import express from 'express'; import request from 'supertest'; import { creditRouter } from '../credit.js'; import { Container } from '../../container/Container.js'; -import { CreditLineStatus } from '../../models/CreditLine.js'; +import { CreditLineStatus, type CreditLine } from '../../models/CreditLine.js'; describe('Credit Routes', () => { let app: express.Application; @@ -20,7 +20,9 @@ describe('Credit Routes', () => { afterEach(() => { // Clear repository data after each test + // eslint-disable-next-line @typescript-eslint/no-explicit-any if (container.creditLineRepository && typeof (container.creditLineRepository as any).clear === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container.creditLineRepository as any).clear(); } }); @@ -247,6 +249,7 @@ describe('Credit Routes', () => { } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = mockService; const response = await request(app) @@ -257,6 +260,7 @@ describe('Credit Routes', () => { expect(response.body.data).toBeNull(); // Restore original service + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = originalService; }); }); @@ -319,6 +323,7 @@ describe('Credit Routes', () => { } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = mockService; const response = await request(app) @@ -334,6 +339,7 @@ describe('Credit Routes', () => { expect(response.body.data).toBeNull(); // Restore original service + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = originalService; }); }); @@ -409,6 +415,7 @@ describe('Credit Routes', () => { } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = mockService; const response = await request(app) @@ -420,6 +427,7 @@ describe('Credit Routes', () => { expect(response.body.data).toBeNull(); // Restore original service + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = originalService; }); }); @@ -460,6 +468,7 @@ describe('Credit Routes', () => { } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = mockService; const response = await request(app) @@ -470,6 +479,7 @@ describe('Credit Routes', () => { expect(response.body.data).toBeNull(); // Restore original service + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = originalService; }); }); @@ -502,7 +512,7 @@ describe('Credit Routes', () => { .expect(200); expect(response.body.data.creditLines).toHaveLength(2); - expect(response.body.data.creditLines.every((cl: any) => cl.walletAddress === walletAddress)).toBe(true); + expect(response.body.data.creditLines.every((cl: CreditLine) => cl.walletAddress === walletAddress)).toBe(true); expect(response.body.error).toBeNull(); }); @@ -525,6 +535,7 @@ describe('Credit Routes', () => { } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = mockService; const response = await request(app) @@ -535,6 +546,7 @@ describe('Credit Routes', () => { expect(response.body.data).toBeNull(); // Restore original service + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._creditLineService = originalService; }); }); diff --git a/src/routes/__tests__/risk.test.ts b/src/routes/__tests__/risk.test.ts index f438c7c..5915e3c 100644 --- a/src/routes/__tests__/risk.test.ts +++ b/src/routes/__tests__/risk.test.ts @@ -15,8 +15,9 @@ interface InvokeArgs { } async function invokeRoute(args: InvokeArgs): Promise<{ status: number; body: unknown }> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const layer = riskRouter.stack.find( - (entry: any) => + (entry: any) => // eslint-disable-line @typescript-eslint/no-explicit-any entry.route?.path === args.path && entry.route?.methods?.[args.method] === true, ); @@ -25,6 +26,7 @@ async function invokeRoute(args: InvokeArgs): Promise<{ status: number; body: un throw new Error(`Route not found: ${args.method.toUpperCase()} ${args.path}`); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlers: Array<(req: Request, res: Response, next: NextFunction) => unknown> = layer.route!.stack.map((entry: any) => entry.handle); @@ -129,7 +131,7 @@ describe('Risk Routes', () => { body: { walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S2' }, }); - expect((response.body as any).data.message).toBe('Using cached risk evaluation'); + expect((response.body as { data: { message: string } }).data.message).toBe('Using cached risk evaluation'); }); it('forces refresh when requested', async () => { @@ -145,7 +147,7 @@ describe('Risk Routes', () => { body: { walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S2', forceRefresh: true }, }); - expect((response.body as any).data.message).toBe('New risk evaluation completed'); + expect((response.body as { data: { message: string } }).data.message).toBe('New risk evaluation completed'); }); it('rejects missing walletAddress via schema validation', async () => { @@ -176,6 +178,7 @@ describe('Risk Routes', () => { it('returns 500 when service throws', async () => { const originalService = container.riskEvaluationService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = { ...originalService, evaluateRisk: async () => { @@ -192,6 +195,7 @@ describe('Risk Routes', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ data: null, error: 'Internal server error' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = originalService; }); }); @@ -208,7 +212,7 @@ describe('Risk Routes', () => { }); expect(response.status).toBe(200); - expect((response.body as any).data.id).toBe(latest!.id); + expect((response.body as { data: { id: string } }).data.id).toBe(latest!.id); }); it('returns 404 when evaluation is missing', async () => { @@ -224,6 +228,7 @@ describe('Risk Routes', () => { it('returns 500 when evaluation fetch throws', async () => { const originalService = container.riskEvaluationService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = { ...originalService, getRiskEvaluation: async () => { @@ -239,6 +244,7 @@ describe('Risk Routes', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ data: null, error: 'Failed to fetch risk evaluation' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = originalService; }); @@ -268,6 +274,7 @@ describe('Risk Routes', () => { it('returns 500 when latest evaluation fetch throws', async () => { const originalService = container.riskEvaluationService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = { ...originalService, getLatestRiskEvaluation: async () => { @@ -283,6 +290,7 @@ describe('Risk Routes', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ data: null, error: 'Failed to fetch latest risk evaluation' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = originalService; }); @@ -299,7 +307,7 @@ describe('Risk Routes', () => { }); expect(response.status).toBe(200); - expect((response.body as any).data.evaluations).toHaveLength(1); + expect((response.body as { data: { evaluations: unknown[] } }).data.evaluations).toHaveLength(1); }); it('rejects invalid query types for history', async () => { @@ -316,6 +324,7 @@ describe('Risk Routes', () => { it('returns 500 when history fetch throws', async () => { const originalService = container.riskEvaluationService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = { ...originalService, getRiskEvaluationHistory: async () => { @@ -331,6 +340,7 @@ describe('Risk Routes', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ data: null, error: 'Failed to fetch risk evaluation history' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (container as any)._riskEvaluationService = originalService; }); }); diff --git a/src/services/__tests__/drawWebhookService.test.ts b/src/services/__tests__/drawWebhookService.test.ts index 5e85de4..47291e3 100644 --- a/src/services/__tests__/drawWebhookService.test.ts +++ b/src/services/__tests__/drawWebhookService.test.ts @@ -144,7 +144,7 @@ describe("DrawWebhookService", () => { initializeWebhooks(); }); - it("should skip webhook delivery when no URLs are configured", () => { + it("should skip webhook delivery when no URLs are configured", async () => { delete process.env.WEBHOOK_URLS; initializeWebhooks(); @@ -161,13 +161,13 @@ describe("DrawWebhookService", () => { }) }; - const result = sendDrawConfirmationWebhook(event); + await expect(sendDrawConfirmationWebhook(event)).resolves.toEqual([]); void expect(result).resolves.toEqual([]); expect(mockFetch).not.toHaveBeenCalled(); }); - it("should skip non-draw confirmation events", () => { + it("should skip non-draw confirmation events", async () => { const event: HorizonEvent = { ledger: 1000, timestamp: "2023-01-01T00:00:00Z", @@ -176,7 +176,7 @@ describe("DrawWebhookService", () => { data: JSON.stringify({}) }; - const result = sendDrawConfirmationWebhook(event); + await expect(sendDrawConfirmationWebhook(event)).resolves.toEqual([]); void expect(result).resolves.toEqual([]); expect(mockFetch).not.toHaveBeenCalled(); diff --git a/src/services/horizonListener.ts b/src/services/horizonListener.ts index cd2f997..c828a47 100644 --- a/src/services/horizonListener.ts +++ b/src/services/horizonListener.ts @@ -206,31 +206,31 @@ export function resetMetrics(): void { // Error handling and classification // --------------------------------------------------------------------------- -function classifyError(error: any): HorizonError { +function classifyError(error: unknown): HorizonError { const horizonError = error as HorizonError; // Rate limit errors (HTTP 429) - if (error.status === 429 || horizonError.code === 'RATE_LIMIT_EXCEEDED') { + if (horizonError.status === 429 || horizonError.code === 'RATE_LIMIT_EXCEEDED') { horizonError.isRateLimit = true; horizonError.isTransient = true; return horizonError; } // Cursor gap errors - if (horizonError.code === 'CURSOR_GAP' || error.message?.includes('cursor gap')) { + if (horizonError.code === 'CURSOR_GAP' || (error instanceof Error && error.message?.includes('cursor gap'))) { horizonError.isCursorGap = true; horizonError.isTransient = true; return horizonError; } // Transient network errors (5xx, timeouts, connection issues) - if (error.status >= 500 || error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET') { + if ((horizonError.status !== undefined && horizonError.status >= 500) || horizonError.code === 'ETIMEDOUT' || horizonError.code === 'ECONNRESET') { horizonError.isTransient = true; return horizonError; } // Client errors (4xx except 429) are not transient - if (error.status >= 400 && error.status < 500 && error.status !== 429) { + if (horizonError.status !== undefined && horizonError.status >= 400 && horizonError.status < 500 && horizonError.status !== 429) { horizonError.isTransient = false; return horizonError; } diff --git a/src/services/jobQueue.ts b/src/services/jobQueue.ts index a46e659..162e151 100644 --- a/src/services/jobQueue.ts +++ b/src/services/jobQueue.ts @@ -159,7 +159,7 @@ export class InMemoryJobQueue implements JobQueue { type: string, handler: JobHandler, ): void { - this.handlers.set(type, handler as JobHandler); + this.handlers.set(type, handler as JobHandler); } start(): void { @@ -225,8 +225,8 @@ export class InMemoryJobQueue implements JobQueue { if (this.processing) return false; const now = Date.now(); - const ready: InternalJob[] = []; - const waiting: InternalJob[] = []; + const ready: InternalJob[] = []; + const waiting: InternalJob[] = []; for (const job of this.pending) { (job.nextRunAt <= now ? ready : waiting).push(job);