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
135 changes: 104 additions & 31 deletions backend/src/services/__tests__/loanEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,123 @@
import { describe, it, expect } from "@jest/globals";
import request from "supertest";
import app from "../../app.js";
import { jest } from "@jest/globals";
import { Keypair } from "@stellar/stellar-sdk";

const token = "test-token";
const adminToken = "test-admin-token";
type MockQueryResult = { rows: unknown[]; rowCount?: number };

describe("POST /loans/:loanId/build-cancel", () => {
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.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<string, { status: string; address: string }> = {
"loan-123": { status: "PENDING", address: BORROWER },
"completed-loan": { status: "COMPLETED", address: BORROWER },
"loan-1": { status: "PENDING", address: BORROWER },
};

const mockQuery: jest.MockedFunction<
(text: string, params?: unknown[]) => Promise<MockQueryResult>
> = jest.fn(async (text: string, params?: unknown[]) => {
const loanId = params?.[0] as string | undefined;
const loan = loanId ? loans[loanId] : undefined;

// requireLoanOwner resolves the borrower from the unified loan_events view.
if (/from\s+loan_events/i.test(text)) {
return { rows: loan ? [{ address: loan.address }] : [] };
}
// Controllers load the loan row to check its status.
if (/from\s+loans\s+where\s+id/i.test(text)) {
return { rows: loan ? [{ id: loanId, status: loan.status }] : [] };
}
// audit_logs INSERT and anything else: no-op.
return { rows: [] };
});

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

// Keep Redis out of the test.
jest.unstable_mockModule("../cacheService.js", () => ({
cacheService: {
get: jest.fn<() => Promise<null>>().mockResolvedValue(null),
set: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
delete: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
},
}));

// Avoid real Stellar RPC; return a deterministic unsigned transaction.
const mockBuildCancelLoanTx = jest
.fn<(borrower: string, loanId: string) => Promise<unknown>>()
.mockResolvedValue({
unsignedTxXdr: "AAAAcancel",
networkPassphrase: "Test",
});
const mockBuildRejectLoanTx = jest
.fn<(admin: string, loanId: string, reason: string) => Promise<unknown>>()
.mockResolvedValue({
unsignedTxXdr: "AAAAreject",
networkPassphrase: "Test",
});

jest.unstable_mockModule("../sorobanService.js", () => ({
sorobanService: {
buildCancelLoanTx: mockBuildCancelLoanTx,
buildRejectLoanTx: mockBuildRejectLoanTx,
},
}));

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 () => {
const response = await request(app)
.post("/loans/loan-123/build-cancel")
.set("Authorization", `Bearer ${token}`);
.post("/api/loans/loan-123/build-cancel")
.set("Authorization", borrowerAuth);

expect(response.status).toBe(200);

expect(response.body.transaction).toBeDefined();
});
});

describe("POST /admin/loans/:loanId/build-reject", () => {
it("should build reject transaction", async () => {
it("should reject non-cancellable loans", async () => {
const response = await request(app)
.post("/admin/loans/loan-123/build-reject")
.set("Authorization", `Bearer ${adminToken}`)
.send({
reason: "Insufficient collateral",
});
.post("/api/loans/completed-loan/build-cancel")
.set("Authorization", borrowerAuth);

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

it("should reject non-cancellable loans", async () => {
const response = await request(app)
.post("/loans/completed-loan/build-cancel")
.set("Authorization", `Bearer ${token}`);
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" });

expect(response.status).toBe(400);
});
expect(response.status).toBe(200);
expect(response.body.transaction).toBeDefined();
});

it("should fail if reason too short", async () => {
const response = await request(app)
.post("/admin/loans/loan-1/build-reject")
.set("Authorization", `Bearer ${adminToken}`)
.send({
reason: "bad",
});
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" });

expect(response.status).toBe(400);
expect(response.status).toBe(400);
});
});
10 changes: 6 additions & 4 deletions backend/src/services/notificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,21 +623,23 @@ class NotificationService {

private mapRow(row: Record<string, unknown>): Notification {
const loanId = row.loan_id != null ? (row.loan_id as number) : undefined;
const actionUrl: string | null =
row.action_url != null ? (row.action_url as string) : null;
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,
type: row.type as NotificationType,
title: row.title as string,
message: row.message as string,
actionUrl,
read: row.read as boolean,
status:
(row.status as NotificationStatus) ?? (row.read ? "read" : "unread"),
createdAt: new Date(row.created_at as string),
};
return loanId !== undefined ? { ...base, loanId } : base;
// Keep optional fields omitted rather than null so the mapped shape is
// consistent (loanId is treated the same way).
const withLoan = loanId !== undefined ? { ...base, loanId } : base;
return actionUrl !== undefined ? { ...withLoan, actionUrl } : withLoan;
}
}

Expand Down
6 changes: 6 additions & 0 deletions backend/src/services/webhookService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ export class WebhookService {
payload: Record<string, unknown>;
attempt_count: number;
};
// Defensive circuit breaker: the SQL filter above already excludes
// deliveries at the retry ceiling, but guard here too so a delivery
// at MAX_RETRY_ATTEMPTS is never re-sent even if it slips through.
if (delivery.attempt_count >= MAX_RETRY_ATTEMPTS) {
continue;
}
await WebhookService.retryWebhookDelivery(
delivery.id,
delivery.subscription_id,
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/app/utils/amount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ export function toStroops(value: string, decimals = STROOP_DECIMALS): bigint | n
const normalizedFraction = fraction.padEnd(decimals, "0");

try {
return BigInt(whole || "0") * BigInt(STROOP_SCALE) + BigInt(normalizedFraction || "0");
// Scale by the requested precision, not the fixed 7-decimal stroop scale,
// so non-XLM assets (e.g. 2-decimal USDC) convert correctly.
const scale = BigInt(10) ** BigInt(decimals);
return BigInt(whole || "0") * scale + BigInt(normalizedFraction || "0");
} catch {
return null;
}
Expand Down
Loading