Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions migrations/002_add_interest_rate_to_credit_lines.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 2 additions & 2 deletions src/__test__/creditRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

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);

Expand All @@ -37,8 +37,8 @@
}

function denyAdmin() {
mockAdminAuth.mockImplementation((_req, res: any, _next) => {
mockAdminAuth.mockImplementation((_req, res: Response, _next) => {

Check failure on line 40 in src/__test__/creditRoute.test.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Argument of type '(_req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>, res: Response, _next: NextFunction) => void' is not assignable to parameter of type '(req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>, res: Response<any, Record<string, any>>, next: NextFunction) => void'.

Check failure on line 40 in src/__test__/creditRoute.test.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Argument of type '(_req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>, res: Response, _next: NextFunction) => void' is not assignable to parameter of type '(req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>, res: Response<any, Record<string, any>>, next: NextFunction) => void'.
res.status(401).json({ error: "Unauthorized: valid X-Admin-Api-Key header is required." });

Check failure on line 41 in src/__test__/creditRoute.test.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

This expression is not callable.

Check failure on line 41 in src/__test__/creditRoute.test.ts

View workflow job for this annotation

GitHub Actions / smoke-test

This expression is not callable.
});
}

Expand Down
7 changes: 7 additions & 0 deletions src/__test__/horizonListener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
} from "../services/horizonListener.js";
import { vi, type Mock } from "vitest";

const baseConfig: HorizonListenerConfig = {

Check failure on line 17 in src/__test__/horizonListener.test.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Cannot redeclare block-scoped variable 'baseConfig'.

Check failure on line 17 in src/__test__/horizonListener.test.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Cannot redeclare block-scoped variable 'baseConfig'.
horizonUrl: "https://horizon-testnet.stellar.org",
contractIds: [],
pollIntervalMs: 5000,
Expand Down Expand Up @@ -392,6 +392,13 @@
// pollOnce()
// ---------------------------------------------------------------------------

const baseConfig: HorizonListenerConfig = {

Check failure on line 395 in src/__test__/horizonListener.test.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Cannot redeclare block-scoped variable 'baseConfig'.

Check failure on line 395 in src/__test__/horizonListener.test.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Cannot redeclare block-scoped variable 'baseConfig'.
horizonUrl: "https://horizon-testnet.stellar.org",
contractIds: [],
pollIntervalMs: 5000,
startLedger: "latest",
};

describe("pollOnce()", () => {

it("completes without throwing when contractIds is empty", async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
34 changes: 27 additions & 7 deletions src/container/Container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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";
Expand All @@ -14,10 +15,13 @@
export class Container {
private static instance: Container;

// Database client
private _dbClient?: DbClient;

Check failure on line 19 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Cannot find name 'DbClient'.

Check failure on line 19 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Cannot find name 'DbClient'.

// Repositories
private _creditLineRepository: CreditLineRepository;
private _riskEvaluationRepository: RiskEvaluationRepository;
private _transactionRepository: TransactionRepository;
private _creditLineRepository!: CreditLineRepository;
private _riskEvaluationRepository!: RiskEvaluationRepository;
private _transactionRepository!: TransactionRepository;

// Services
private _creditLineService: CreditLineService;
Expand All @@ -26,16 +30,14 @@
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);
this._riskEvaluationService = new RiskEvaluationService(
this._riskEvaluationRepository,
createRiskProvider(),

Check failure on line 40 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Cannot find name 'createRiskProvider'.

Check failure on line 40 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Cannot find name 'createRiskProvider'.
);

// Initialize Soroban client and reconciliation services
Expand All @@ -52,6 +54,24 @@
);
}

private initializeRepositories(): void {
const useDatabase = process.env.DATABASE_URL && process.env.NODE_ENV !== 'test';

if (useDatabase) {
// Use PostgreSQL repositories
this._dbClient = getConnection();

Check failure on line 62 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Cannot find name 'getConnection'.

Check failure on line 62 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Cannot find name 'getConnection'.
this._creditLineRepository = new PostgresCreditLineRepository(this._dbClient);

Check failure on line 63 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Property 'findAllWithCursor' is missing in type 'PostgresCreditLineRepository' but required in type 'CreditLineRepository'.

Check failure on line 63 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Property 'findAllWithCursor' is missing in type 'PostgresCreditLineRepository' but required in type 'CreditLineRepository'.
// 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();
Expand Down Expand Up @@ -106,7 +126,7 @@
this._riskEvaluationRepository = repositories.riskEvaluationRepository;
this._riskEvaluationService = new RiskEvaluationService(
this._riskEvaluationRepository,
createRiskProvider(),

Check failure on line 129 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test (20.x)

Cannot find name 'createRiskProvider'.

Check failure on line 129 in src/container/Container.ts

View workflow job for this annotation

GitHub Actions / smoke-test

Cannot find name 'createRiskProvider'.
);
}

Expand Down
110 changes: 110 additions & 0 deletions src/container/__tests__/Container.postgres.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
20 changes: 17 additions & 3 deletions src/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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')
);
Expand All @@ -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');
});
});

Expand Down
Loading
Loading