Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6526655
test: add unit tests for SorobanService and mock Stellar RPC
devfoma Jun 18, 2026
09b655a
Fix webhook retry processor and delete duplicate scheduler
Sam-Rytech Jun 19, 2026
138ed44
feat(admin): add approve_loan endpoint for admin loan approval
osasfaith Jun 19, 2026
9ad4b07
style: fix Promise.race formatting and remove unused import
devfoma Jun 19, 2026
bf1ddaf
fix: resolve migration timestamp collisions
MerlinTheWhiz Jun 19, 2026
72c64a4
fix: guard event indexer poll cycle with Postgres advisory lock
nonsobethel0-dev Jun 19, 2026
d815095
fix: add restart-resume test and advisory lock observability log
nonsobethel0-dev Jun 19, 2026
9e3c2e6
Merge pull request #24 from devfoma/fix/soroban-service-tests
ogazboiz Jun 19, 2026
06f0515
Merge pull request #30 from MerlinTheWhiz/fix/migration-timestamp-col…
ogazboiz Jun 19, 2026
7de1647
Merge pull request #29 from osasfaith/feat/add-admin-approve-loan-end…
ogazboiz Jun 19, 2026
5e53c09
fix: address review — add getClient to connection mocks, fix TS types…
nonsobethel0-dev Jun 20, 2026
8265862
chore: add CI workflow
Kaycee276 Jun 20, 2026
d134e20
Merge pull request #31 from nonsobethel0-dev/fix/issue-5-indexer-advi…
ogazboiz Jun 20, 2026
372fb33
Merge upstream main
Kaycee276 Jun 20, 2026
661be8c
fix: address CI feedback
Kaycee276 Jun 20, 2026
b321a3e
chore(infra): add Docker HEALTHCHECK and bound /health with per-check…
iyanumajekodunmi756 Jun 21, 2026
c8746bb
Merge pull request #36 from iyanumajekodunmi756/chore/issue-16-docker…
ogazboiz Jun 21, 2026
dd5e92e
Merge pull request #34 from Kaycee276/fix-12-ci-workflow
ogazboiz Jun 21, 2026
c3c4886
Fix: resolve dangling imports and restore multi-instance lock semantics
Sam-Rytech Jun 21, 2026
569bd74
Fix webhook retry processor and delete duplicate scheduler
Sam-Rytech Jun 19, 2026
c1a39e2
Fix: resolve dangling imports and restore multi-instance lock semantics
Sam-Rytech Jun 21, 2026
ed3d6ee
Fix: format distributedLock.test.ts
Sam-Rytech Jun 21, 2026
6b9de25
Fix: resolve webhook retry processor conflict and share retry config
Sam-Rytech Jun 21, 2026
e6af9c4
Fix: resolve webhook retry processor conflict and share retry config
Sam-Rytech Jun 23, 2026
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
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist
coverage
migrations
src/tests/jest.setup.js
src/utils/demo.ts
__mocks__/redis.js
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: CI

on:
push:
branches:
- main
pull_request:

jobs:
build-and-test:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: remitlend
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
DATABASE_URL: postgres://postgres:password@localhost:5432/remitlend
REDIS_URL: redis://localhost:6379
INTERNAL_API_KEY: test-api-key
JWT_SECRET: test-secret
NODE_ENV: test
STELLAR_RPC_URL: https://soroban-testnet.stellar.org
STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
LOAN_MANAGER_CONTRACT_ID: CDUMMYLOANMANAGERCONTRACTIDXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
LENDING_POOL_CONTRACT_ID: CDUMMYLENDINGPOOLCONTRACTIDXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
POOL_TOKEN_ADDRESS: CDUMMYPOOLTOKENADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
LOAN_MANAGER_ADMIN_SECRET: SDUMMYSECRETXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
FRONTEND_URL: http://localhost:3000
SCORE_DELTA_REPAY: "10"
SCORE_DELTA_DEFAULT: "-50"
SCORE_DELTA_LATE: "-10"

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Format Check
run: npm run format:check

- name: Typecheck
run: npm run typecheck

- name: Build
run: npm run build

- name: Run Database Migrations
run: npm run migrate:up

- name: Test
run: npm run test
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY migrations ./migrations
COPY src ./src
RUN npx tsc -p tsconfig.build.json
RUN npm run build

# Production Stage
FROM node:22-alpine AS production
Expand All @@ -32,4 +32,14 @@ USER appuser

EXPOSE 3001

CMD ["node", "dist/index.js"]
# Healthcheck: probe the /health endpoint on the exposed port.
# `wget` is bundled in busybox on the alpine image, so no extra packages are
# required. `--spider` performs a HEAD-style check without downloading the
# response body and exits non-zero for any HTTP 4xx/5xx (including our 503
# "degraded" response) or network failure, which Docker correctly reports as
# unhealthy. `--timeout=8` is a defense-in-depth bound so `wget` exits cleanly
# before Docker's `--timeout=10s` SIGKILL kicks in.
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --spider --quiet --tries=1 --timeout=8 http://127.0.0.1:3001/health || exit 1

CMD ["node", "dist/index.js"]
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,47 @@ Core tables are created by these migrations (run in filename order):
| `1773000000002_loan-history.js` | `loan_history` |
| `1773000000003_indexed-events.js` | `indexed_events` |
| `1774000000004_scores-add-created-at.js` | adds `created_at` to `scores` (idempotent) |
| `1777000000007_unique-loan-status-events.js` | dedupes and enforces unique status events per loan |
| `1777000000008_unique-loan-status-events.js` | dedupes and enforces unique status events per loan |

### Migration naming convention

Each migration file follows the pattern `<timestamp>_<description>.js`. The timestamp prefix is a 13-digit Unix millisecond value (generated by `Date.now().toString()`) that determines apply order — migrations run in strictly ascending timestamp order.

To avoid collisions:

- **Always** use `npm run migrate:create <name>` to generate new migrations — it calls `Date.now()` automatically and guarantees uniqueness.
- If you must create a migration file manually, first check the largest existing timestamp in `migrations/` and ensure your new prefix is strictly larger.
- Never submit a PR where two migration files share the same numeric prefix — the apply order becomes non-deterministic.

### Renaming an already-applied migration (existing databases)

If you rename a migration file that has already been applied to a database, `node-pg-migrate` will reject the run with:

```
Error: Not run migration <new-name> is preceding already run migration <old-name>
```

This happens because `pgmigrations` still references the old filename while the filesystem has the new one. Fix it by syncing the tracking table before re-running `npm run migrate:up`:

```sql
UPDATE pgmigrations
SET name = '1777000000008_unique-loan-status-events'
WHERE name = '1777000000007_unique-loan-status-events';

UPDATE pgmigrations
SET name = '1778000000009_transaction-submissions'
WHERE name = '1778000000008_transaction-submissions';

UPDATE pgmigrations
SET name = '1786000000017_webhook-max-attempts'
WHERE name = '1786000000016_webhook-max-attempts';

UPDATE pgmigrations
SET name = '1788000000019_unified-contract-events'
WHERE name = '1788000000018_unified-contract-events';
```

Afterwards, `npm run migrate:up` will run normally.

With Docker Compose from the repo root, the `backend` service runs `migrate:up` before `npm run dev` so the schema is applied automatically when the database is healthy.

Expand Down
1 change: 0 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ const config: Config = {
},
},
moduleNameMapper: {
"^redis$": "<rootDir>/__mocks__/redis.js",
"^(./|../)(.*)\\.js$": "$1$2",
},
};
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"type": "module",
"scripts": {
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"lint": "eslint .",
Expand Down
146 changes: 146 additions & 0 deletions src/__tests__/adminApproveLoan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 };

const VALID_API_KEY = "test-internal-key";
const TEST_ADMIN = Keypair.random().publicKey();
const TEST_BORROWER = Keypair.random().publicKey();

process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!";
process.env.INTERNAL_API_KEY = VALID_API_KEY;
process.env.ADMIN_WALLETS = TEST_ADMIN;

const mockQuery: jest.MockedFunction<
(text: string, params?: unknown[]) => Promise<MockQueryResult>
> = jest.fn();

const mockRelease = jest.fn();
const mockClient: any = {

Check warning on line 21 in src/__tests__/adminApproveLoan.test.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
query: mockQuery,
release: mockRelease,
};

jest.unstable_mockModule("../db/connection.js", () => ({
default: { query: mockQuery },
query: mockQuery,
getClient: jest
.fn<() => Promise<typeof mockClient>>()
.mockResolvedValue(mockClient),
closePool: jest.fn(),
withTransaction: jest.fn(),
}));

jest.unstable_mockModule("../services/cacheService.js", () => ({
cacheService: {
get: jest.fn<() => Promise<any>>().mockResolvedValue(null),

Check warning on line 38 in src/__tests__/adminApproveLoan.test.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
set: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
delete: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
},
}));

const mockBuildApproveLoanTx =
jest.fn<
(
adminPublicKey: string,
loanId: number,
) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }>
>();
const mockSubmitSignedTx =
jest.fn<
(
signedTxXdr: string,
) => Promise<{ txHash: string; status: string; resultXdr?: string }>
>();

jest.unstable_mockModule("../services/sorobanService.js", () => ({
sorobanService: {
buildApproveLoanTx: mockBuildApproveLoanTx,
submitSignedTx: mockSubmitSignedTx,
},
}));

await import("../db/connection.js");
await import("../services/sorobanService.js");
const { default: app } = await import("../app.js");

const mockedQuery = mockQuery;

const bearer = (publicKey: string) => ({
Authorization: `Bearer ${generateJwtToken(publicKey)}`,
});

beforeEach(() => {
mockedQuery.mockReset();
jest.clearAllMocks();
});

afterAll(() => {
delete process.env.INTERNAL_API_KEY;
delete process.env.JWT_SECRET;
delete process.env.ADMIN_WALLETS;
});

// ---------------------------------------------------------------------------
// POST /admin/approve-loan
// ---------------------------------------------------------------------------
describe("POST /admin/approve-loan", () => {
it("should build an approve_loan transaction for admin", async () => {
mockBuildApproveLoanTx.mockResolvedValueOnce({
unsignedTxXdr: "unsigned-approve-xdr",
networkPassphrase: "Test SDF Network ; September 2015",
});

const response = await request(app)
.post("/api/admin/approve-loan")
.set(bearer(TEST_ADMIN))
.send({ loanId: 1 });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.loanId).toBe(1);
expect(response.body.unsignedTxXdr).toBe("unsigned-approve-xdr");
expect(response.body.networkPassphrase).toBe(
"Test SDF Network ; September 2015",
);
expect(mockBuildApproveLoanTx).toHaveBeenCalledWith(TEST_ADMIN, 1);
});

it("should reject non-admin user", async () => {
const response = await request(app)
.post("/api/admin/approve-loan")
.set(bearer(TEST_BORROWER))
.send({ loanId: 1 });

expect(response.status).toBe(403);
});

it("should reject missing loanId", async () => {
const response = await request(app)
.post("/api/admin/approve-loan")
.set(bearer(TEST_ADMIN))
.send({});

expect(response.status).toBe(400);
});

it("should reject invalid loanId (non-positive integer)", async () => {
const response = await request(app)
.post("/api/admin/approve-loan")
.set(bearer(TEST_ADMIN))
.send({ loanId: -1 });

expect(response.status).toBe(400);
});

it("should reject missing authentication", async () => {
const response = await request(app)
.post("/api/admin/approve-loan")
.send({ loanId: 1 });

expect(response.status).toBe(401);
});
});
20 changes: 19 additions & 1 deletion src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
const supportedWebhookEventTypes = [
"LoanRequested",
Expand Down Expand Up @@ -62,9 +63,26 @@ const supportedWebhookEventTypes = [
"PoolUnpaused",
] as const;

// Default advisory-lock client returned by getClient().
// Returns acquired:true for pg_try_advisory_lock so that pollOnce() proceeds
// normally in tests that call it directly. Tests that need a different
// behaviour (e.g. lock-not-acquired) should override getClient on the mock.
const mockLockClient = {
query: jest.fn(async (sql: string) => {
if (sql.includes("pg_try_advisory_lock")) {
return { rows: [{ acquired: true }], rowCount: 1 };
}
// pg_advisory_unlock
return { rows: [], rowCount: 0 };
}),
release: jest.fn(),
};

jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
getClient: jest.fn(),
getClient: jest
.fn<() => Promise<typeof mockLockClient>>()
.mockResolvedValue(mockLockClient),
closePool: jest.fn(),
withTransaction: jest.fn(
async (
Expand Down
Loading
Loading