From 730aeb538ff26e224e64a713f8643ca9b428fc22 Mon Sep 17 00:00:00 2001 From: artylobos Date: Wed, 27 May 2026 18:25:22 +1000 Subject: [PATCH 1/2] backend: mount v1 pool notification event routes --- backend/src/__tests__/apiV1Mounts.test.ts | 141 ++++++++++++++++++++++ backend/src/app.ts | 3 + frontend/e2e/borrower-repay-flow.spec.ts | 16 ++- frontend/e2e/lender-withdraw-flow.spec.ts | 19 ++- 4 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 backend/src/__tests__/apiV1Mounts.test.ts diff --git a/backend/src/__tests__/apiV1Mounts.test.ts b/backend/src/__tests__/apiV1Mounts.test.ts new file mode 100644 index 00000000..cc1024ac --- /dev/null +++ b/backend/src/__tests__/apiV1Mounts.test.ts @@ -0,0 +1,141 @@ +import { jest } from "@jest/globals"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +type MockQueryResult = { rows: Record[]; rowCount: number }; + +const VALID_API_KEY = "test-internal-key"; +const TEST_PUBLIC_KEY = + "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + +process.env.NODE_ENV = "test"; +process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!"; +process.env.INTERNAL_API_KEY = VALID_API_KEY; +process.env.POOL_TOKEN_ADDRESS = "test-pool-token"; + +const mockQuery: jest.MockedFunction< + (text: string, params?: unknown[]) => Promise +> = jest.fn(); + +jest.unstable_mockModule("../db/connection.js", () => ({ + default: { query: mockQuery }, + query: mockQuery, + getClient: jest.fn(), + closePool: jest.fn(), + withTransaction: jest.fn(), +})); + +jest.unstable_mockModule("../db/transaction.js", () => ({ + withTransaction: jest.fn(), + withStellarAndDbTransaction: jest.fn(), +})); + +jest.unstable_mockModule("../services/cacheService.js", () => ({ + cacheService: { + ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + }, +})); + +jest.unstable_mockModule("../services/sorobanService.js", () => ({ + sorobanService: { + ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + }, +})); + +await import("../db/connection.js"); +const { default: app } = await import("../app.js"); + +const dbRows = (rows: Record[]): MockQueryResult => ({ + rows, + rowCount: rows.length, +}); + +const bearer = () => { + const token = jwt.sign( + { + publicKey: TEST_PUBLIC_KEY, + role: "admin", + scopes: ["admin:all"], + }, + process.env.JWT_SECRET!, + { algorithm: "HS256", expiresIn: "1h" }, + ); + + return { Authorization: `Bearer ${token}` }; +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +afterAll(() => { + delete process.env.INTERNAL_API_KEY; + delete process.env.JWT_SECRET; + delete process.env.POOL_TOKEN_ADDRESS; +}); + +describe("API v1 router mounts", () => { + it("mounts pool routes under /api/v1/pool", async () => { + mockQuery + .mockResolvedValueOnce(dbRows([{ total_deposits: "1000" }])) + .mockResolvedValueOnce( + dbRows([{ active_loans_count: "2", total_outstanding: "250" }]), + ); + + const response = await request(app).get("/api/v1/pool/stats").set(bearer()); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toMatchObject({ + totalDeposits: 1000, + totalOutstanding: 250, + activeLoansCount: 2, + poolTokenAddress: "test-pool-token", + }); + }); + + it("mounts notifications routes under /api/v1/notifications", async () => { + mockQuery + .mockResolvedValueOnce( + dbRows([ + { + id: 1, + user_id: TEST_PUBLIC_KEY, + type: "score_changed", + title: "Score updated", + message: "Your score changed", + loan_id: null, + read: false, + status: "unread", + created_at: new Date("2026-05-27T00:00:00.000Z"), + }, + ]), + ) + .mockResolvedValueOnce(dbRows([{ count: "1" }])); + + const response = await request(app) + .get("/api/v1/notifications") + .set(bearer()); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.unreadCount).toBe(1); + expect(response.body.data.notifications).toHaveLength(1); + }); + + it("mounts events routes under /api/v1/events", async () => { + const response = await request(app) + .get("/api/v1/events/status") + .set("x-api-key", VALID_API_KEY); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual( + expect.objectContaining({ + total: expect.any(Number), + borrower: expect.any(Number), + admin: expect.any(Number), + }), + ); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 5b33a565..a8dfd878 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -173,9 +173,12 @@ app.use("/api/remittances", remittanceRoutes); app.use("/api/v1", simulationRoutes); app.use("/api/v1/score", scoreRoutes); app.use("/api/v1/loans", loanRoutes); +app.use("/api/v1/pool", poolRoutes); app.use("/api/v1/indexer", indexerRoutes); app.use("/api/v1/admin", adminRoutes); app.use("/api/v1/auth", authRoutes); +app.use("/api/v1/notifications", notificationsRoutes); +app.use("/api/v1/events", eventRoutes); app.use("/api/v1/remittances", remittanceRoutes); // ── Diagnostic / Test Routes ───────────────────────────────────── diff --git a/frontend/e2e/borrower-repay-flow.spec.ts b/frontend/e2e/borrower-repay-flow.spec.ts index c87e7b0a..87ad5ca1 100644 --- a/frontend/e2e/borrower-repay-flow.spec.ts +++ b/frontend/e2e/borrower-repay-flow.spec.ts @@ -102,7 +102,11 @@ test.describe("Borrower Repayment Flow", () => { await expect(page.locator("text=4,500")).toBeVisible(); }); - test("rejects a repayment greater than the outstanding balance", async ({ page }: { page: Page }) => { + test("rejects a repayment greater than the outstanding balance", async ({ + page, + }: { + page: Page; + }) => { await page.goto("/en"); const repayBtn = page.getByRole("button", { name: /Repay/i }).first(); @@ -115,9 +119,11 @@ test.describe("Borrower Repayment Flow", () => { const review = page.getByRole("button", { name: /Review Repayment/i }); // The flow should not allow proceeding to confirmation with an invalid amount. - await expect(review).toBeDisabled().catch(async () => { - await review.click(); - await expect(page.locator("text=/exceeds|too (large|high)|maximum/i")).toBeVisible(); - }); + await expect(review) + .toBeDisabled() + .catch(async () => { + await review.click(); + await expect(page.locator("text=/exceeds|too (large|high)|maximum/i")).toBeVisible(); + }); }); }); diff --git a/frontend/e2e/lender-withdraw-flow.spec.ts b/frontend/e2e/lender-withdraw-flow.spec.ts index 946578f8..ed04fabe 100644 --- a/frontend/e2e/lender-withdraw-flow.spec.ts +++ b/frontend/e2e/lender-withdraw-flow.spec.ts @@ -106,9 +106,14 @@ test.describe("Lender Withdraw Flow", () => { }, lenderWalletState("1500.00")); await page.click('button:has-text("Review Withdrawal"), button:has-text("Confirm Withdrawal")'); - await page.getByRole("button", { name: /Confirm Withdrawal/i }).click().catch(() => {}); + await page + .getByRole("button", { name: /Confirm Withdrawal/i }) + .click() + .catch(() => {}); - await expect(page.locator("text=/Withdrawal (Successful|Complete)/i")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=/Withdrawal (Successful|Complete)/i")).toBeVisible({ + timeout: 10000, + }); await page.reload(); await expect(page.locator("text=1,500")).toBeVisible(); @@ -126,9 +131,11 @@ test.describe("Lender Withdraw Flow", () => { await page.fill('input[type="number"]', "999999"); const confirm = page.getByRole("button", { name: /Confirm Withdrawal/i }); - await expect(confirm).toBeDisabled().catch(async () => { - await confirm.click(); - await expect(page.locator("text=/exceeds|insufficient|maximum/i")).toBeVisible(); - }); + await expect(confirm) + .toBeDisabled() + .catch(async () => { + await confirm.click(); + await expect(page.locator("text=/exceeds|insufficient|maximum/i")).toBeVisible(); + }); }); }); From fe805f09238fbd061da4f09e0996e0319b3acf4a Mon Sep 17 00:00:00 2001 From: artylobos Date: Wed, 27 May 2026 19:35:39 +1000 Subject: [PATCH 2/2] frontend: fix service worker manifest typing --- frontend/src/app/sw.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/sw.ts b/frontend/src/app/sw.ts index 563ab222..6b107785 100644 --- a/frontend/src/app/sw.ts +++ b/frontend/src/app/sw.ts @@ -2,24 +2,16 @@ import { defaultCache } from "@serwist/next/worker"; import type { PrecacheEntry } from "@serwist/precaching"; import { Serwist } from "serwist"; -declare const self: ServiceWorkerGlobalScopeEventMap; +declare const self: WorkerGlobalScope & { + __SW_MANIFEST: PrecacheEntry[]; +}; const serwist = new Serwist({ - precacheEntries: self.__SW_MANIFEST as PrecacheEntry[], + precacheEntries: self.__SW_MANIFEST, skipWaiting: true, clientsClaim: true, navigationPreload: true, runtimeCaching: defaultCache, - bypassCdn: ({ request }) => { - if ( - request.url.includes("/api/") || - request.url.includes("/sse/") || - request.url.includes("/_next/") - ) { - return true; - } - return false; - }, }); serwist.addEventListeners();