diff --git a/backend/src/services/__tests__/loanEndpoints.test.ts b/backend/src/services/__tests__/loanEndpoints.test.ts index 607ca3cf..1a482669 100644 --- a/backend/src/services/__tests__/loanEndpoints.test.ts +++ b/backend/src/services/__tests__/loanEndpoints.test.ts @@ -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 = { + "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 +> = 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>().mockResolvedValue(null), + set: jest.fn<() => Promise>().mockResolvedValue(undefined), + delete: jest.fn<() => Promise>().mockResolvedValue(undefined), + ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + }, +})); + +// Avoid real Stellar RPC; return a deterministic unsigned transaction. +const mockBuildCancelLoanTx = jest + .fn<(borrower: string, loanId: string) => Promise>() + .mockResolvedValue({ + unsignedTxXdr: "AAAAcancel", + networkPassphrase: "Test", + }); +const mockBuildRejectLoanTx = jest + .fn<(admin: string, loanId: string, reason: string) => Promise>() + .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); + }); }); diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts index e4d38111..7772d054 100644 --- a/backend/src/services/notificationService.ts +++ b/backend/src/services/notificationService.ts @@ -623,21 +623,23 @@ class NotificationService { private mapRow(row: Record): 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; } } diff --git a/backend/src/services/webhookService.ts b/backend/src/services/webhookService.ts index 79d356d4..15aacf3a 100644 --- a/backend/src/services/webhookService.ts +++ b/backend/src/services/webhookService.ts @@ -343,6 +343,12 @@ export class WebhookService { payload: Record; 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, diff --git a/frontend/src/app/utils/amount.ts b/frontend/src/app/utils/amount.ts index 1ac125ea..5ab10b45 100644 --- a/frontend/src/app/utils/amount.ts +++ b/frontend/src/app/utils/amount.ts @@ -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; }