From 3ba10b8fab6a8e6771c32cf37cf32d411cc73c65 Mon Sep 17 00:00:00 2001 From: Akpolo Ogagaoghene Prince Date: Mon, 1 Jun 2026 13:39:16 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20repair=20red=20CI=20=E2=80=94=20Express?= =?UTF-8?q?=205=20validation,=20pagination=20coercion,=20build=20config,?= =?UTF-8?q?=20frontend=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - validation: req.query/req.params are getter-only in Express 5, so reassigning them threw and every validateQuery/validateParams route returned 500. Shadow them with Object.defineProperty so coerced/validated values persist. - pagination: parsePositiveInteger now accepts Zod-coerced numbers, so ?limit=N is honored instead of silently falling back to the default. - build script points at tsconfig.build.json so it emits dist/ and excludes tests (previously used the noEmit, test-including config and failed). - tests: fix shadowed mockQuery in remittanceService, key digest mock by user, complete db/connection ESM mocks, and align LoanRequested event shape with the contract emission (Symbol, loan_id, borrower) plus its supported-types mock. Frontend - CreditScoreGauge: remove duplicated component body left by a bad merge (variable redeclarations and a rules-of-hooks violation). - charts: use recharts 3 TooltipContentProps with a function content prop. --- backend/package.json | 2 +- backend/src/__tests__/apiV1Mounts.test.ts | 1 + backend/src/__tests__/eventIndexer.test.ts | 7 +++- .../src/__tests__/notificationDigest.test.ts | 18 +++++++--- .../src/__tests__/remittanceService.test.ts | 4 --- backend/src/middleware/validation.ts | 10 +++++- .../services/__tests__/eventIndexer.test.ts | 1 + backend/src/utils/pagination.ts | 16 ++++++--- .../charts/CreditScoreTrendChart.tsx | 6 ++-- .../app/components/charts/RiskTierChart.tsx | 6 ++-- .../components/charts/YieldEarningsChart.tsx | 6 ++-- .../app/components/ui/CreditScoreGauge.tsx | 33 +++---------------- 12 files changed, 56 insertions(+), 54 deletions(-) diff --git a/backend/package.json b/backend/package.json index 6d12299f..1c2900ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "nodemon --watch src --ext ts --exec tsx src/index.ts", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.build.json", "typecheck": "tsc -p tsconfig.build.json --noEmit", "start": "node dist/index.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", diff --git a/backend/src/__tests__/apiV1Mounts.test.ts b/backend/src/__tests__/apiV1Mounts.test.ts index 6d4f0dcd..3bf25395 100644 --- a/backend/src/__tests__/apiV1Mounts.test.ts +++ b/backend/src/__tests__/apiV1Mounts.test.ts @@ -20,6 +20,7 @@ jest.unstable_mockModule("../db/connection.js", () => ({ query: mockQuery, getClient: jest.fn(), closePool: jest.fn(), + withTransaction: jest.fn(), })); // ── notificationService mock ───────────────────────────────────────────────── diff --git a/backend/src/__tests__/eventIndexer.test.ts b/backend/src/__tests__/eventIndexer.test.ts index 87bd037b..a8904e72 100644 --- a/backend/src/__tests__/eventIndexer.test.ts +++ b/backend/src/__tests__/eventIndexer.test.ts @@ -173,9 +173,14 @@ function makeRawEvent(params: { switch (params.type) { case "LoanRequested": + // Contract emits (Symbol, loan_id, borrower) with amount as the value. return { ...base, - topic: [scSymbol("LoanRequested"), scAddress(borrower)], + topic: [ + scSymbol("LoanRequested"), + scU32(params.loanId ?? 1), + scAddress(borrower), + ], value: scI128(params.amount ?? 500), }; case "LoanApproved": diff --git a/backend/src/__tests__/notificationDigest.test.ts b/backend/src/__tests__/notificationDigest.test.ts index 3b044e04..62978d75 100644 --- a/backend/src/__tests__/notificationDigest.test.ts +++ b/backend/src/__tests__/notificationDigest.test.ts @@ -109,10 +109,18 @@ describe("notification digest batching", () => { const user1 = "GUSER1111111111111111111111111111111111111111111111111111"; const user2 = "GUSER2222222222222222222222222222222222222222222222222222"; - mockQuery - .mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] }) - .mockResolvedValueOnce({ rows: [{ digest_frequency: "weekly" }] }) - .mockResolvedValueOnce({ rows: [{ digest_frequency: "off" }] }); + // A user has a single digest preference, so the lookup must be keyed by + // user id rather than by call order: user1 is always daily, user2 weekly. + mockQuery.mockImplementation(async (_text, params) => { + const uid = (params as unknown[] | undefined)?.[0]; + if (uid === user1) { + return { rows: [{ digest_frequency: "daily" }] }; + } + if (uid === user2) { + return { rows: [{ digest_frequency: "weekly" }] }; + } + return { rows: [] }; + }); const notifications = [ { userId: user1, message: "Loan 1 due", loanId: 1 }, @@ -125,7 +133,7 @@ describe("notification digest batching", () => { notifications, ); - expect(grouped.size).toBe(3); + expect(grouped.size).toBe(2); expect(grouped.get(`${user1}:daily`)).toHaveLength(2); expect(grouped.get(`${user2}:weekly`)).toHaveLength(1); }); diff --git a/backend/src/__tests__/remittanceService.test.ts b/backend/src/__tests__/remittanceService.test.ts index b213e805..883f7e15 100644 --- a/backend/src/__tests__/remittanceService.test.ts +++ b/backend/src/__tests__/remittanceService.test.ts @@ -104,15 +104,11 @@ describe("remittanceService.createRemittance", () => { }); describe("remittanceService.getRemittances with filters", () => { - let mockQuery: jest.MockedFunction; - beforeEach(() => { jest.clearAllMocks(); - mockQuery = jest.fn(); }); it("filters remittances by status", async () => { - const { query: queryModule } = await import("../db/connection.js"); mockQuery.mockResolvedValueOnce({ rows: [ { diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index abd3606e..f6a865af 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -12,7 +12,15 @@ const validateSource = (schema: ZodType, source: ValidationSource) => { : source === "query" ? req.query : req.params; - req[source] = schema.parse(data); + // In Express 5, `req.query` and `req.params` are getter-only and cannot + // be reassigned (`req.query = ...` throws). Shadow them with a writable + // data property so coerced/validated values are persisted for handlers. + Object.defineProperty(req, source, { + value: schema.parse(data), + writable: true, + enumerable: true, + configurable: true, + }); next(); } catch (error) { next(error); diff --git a/backend/src/services/__tests__/eventIndexer.test.ts b/backend/src/services/__tests__/eventIndexer.test.ts index 5109cfbb..9bcb355d 100644 --- a/backend/src/services/__tests__/eventIndexer.test.ts +++ b/backend/src/services/__tests__/eventIndexer.test.ts @@ -287,6 +287,7 @@ beforeAll(async () => { "PoolPaused", "PoolUnpaused", "LoanApprv", + "LoanLiquidated", ], })); diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts index 680fc6cc..c9b89cdb 100644 --- a/backend/src/utils/pagination.ts +++ b/backend/src/utils/pagination.ts @@ -142,20 +142,28 @@ function parsePositiveInteger( fallback: number, max?: number, ): number { - if (typeof value !== "string") { + // Accept both raw query strings and values already coerced to numbers by + // Zod validation middleware (e.g. `z.coerce.number()`). + let parsed: number; + if (typeof value === "number") { + parsed = value; + } else if (typeof value === "string") { + parsed = Number.parseInt(value, 10); + } else { return fallback; } - const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 0) { return fallback; } + const truncated = Math.trunc(parsed); + if (max !== undefined) { - return Math.min(parsed, max); + return Math.min(truncated, max); } - return parsed; + return truncated; } function parseDateRange(value: unknown): { start: Date; end: Date } | null { diff --git a/frontend/src/app/components/charts/CreditScoreTrendChart.tsx b/frontend/src/app/components/charts/CreditScoreTrendChart.tsx index c9806305..db3ef393 100644 --- a/frontend/src/app/components/charts/CreditScoreTrendChart.tsx +++ b/frontend/src/app/components/charts/CreditScoreTrendChart.tsx @@ -9,7 +9,7 @@ import { Tooltip, ResponsiveContainer, Legend, - TooltipProps, + TooltipContentProps, } from "recharts"; import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card"; import { TrendingUp, TrendingDown } from "lucide-react"; @@ -34,7 +34,7 @@ export function CreditScoreTrendChart({ data, className }: CreditScoreTrendChart const isPositive = scoreDiff >= 0; // Custom tooltip - const CustomTooltip = ({ active, payload }: TooltipProps) => { + const CustomTooltip = ({ active, payload }: TooltipContentProps) => { if (active && payload && payload.length) { const data = (payload[0] as unknown as { payload: CreditScoreDataPoint }).payload; return ( @@ -97,7 +97,7 @@ export function CreditScoreTrendChart({ data, className }: CreditScoreTrendChart className: "text-xs text-gray-600 dark:text-zinc-400", }} /> - } /> + sum + d.count, 0); - const CustomTooltip = ({ active, payload }: TooltipProps) => { + const CustomTooltip = ({ active, payload }: TooltipContentProps) => { if (active && payload && payload.length) { const d = (payload[0] as unknown as { payload: RiskTierDataPoint }).payload; const pct = total > 0 ? ((d.count / total) * 100).toFixed(1) : "0.0"; @@ -67,7 +67,7 @@ export function RiskTierChart({ data, className }: RiskTierChartProps) { tickLine={{ stroke: "currentColor" }} className="text-xs text-gray-600 dark:text-zinc-400" /> - } /> + {data.map((entry, index) => ( diff --git a/frontend/src/app/components/charts/YieldEarningsChart.tsx b/frontend/src/app/components/charts/YieldEarningsChart.tsx index 34536e68..a3653518 100644 --- a/frontend/src/app/components/charts/YieldEarningsChart.tsx +++ b/frontend/src/app/components/charts/YieldEarningsChart.tsx @@ -9,7 +9,7 @@ import { Tooltip, ResponsiveContainer, Legend, - TooltipProps, + TooltipContentProps, } from "recharts"; import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card"; import { DollarSign, TrendingUp } from "lucide-react"; @@ -35,7 +35,7 @@ export function YieldEarningsChart({ data, className }: YieldEarningsChartProps) : "0.00"; // Custom tooltip - const CustomTooltip = ({ active, payload }: TooltipProps) => { + const CustomTooltip = ({ active, payload }: TooltipContentProps) => { if (active && payload && payload.length) { const data = (payload[0] as unknown as { payload: YieldDataPoint }).payload; return ( @@ -137,7 +137,7 @@ export function YieldEarningsChart({ data, className }: YieldEarningsChartProps) className: "text-xs text-gray-600 dark:text-zinc-400", }} /> - } /> + (numericScore == null ? BANDS[0] : getBand(numericScore)), [ - numericScore, - ]); + const band = useMemo( + () => (numericScore == null ? BANDS[0] : getBand(numericScore)), + [numericScore], + ); const delta = previousScore != null && numericScore != null ? numericScore - previousScore : null; const cx = 120; @@ -176,32 +177,6 @@ export function CreditScoreGauge({ ); } - const band = useMemo(() => getBand(numericScore), [numericScore]); - const delta = previousScore != null ? numericScore - previousScore : null; - - const cx = 120; - const cy = 120; - const r = 100; - const startAngle = -120; - const endAngle = 120; - const totalArc = endAngle - startAngle; - - const clampedScore = Math.max(min, Math.min(max, numericScore)); - const fraction = (clampedScore - min) / (max - min); - const scoreAngle = startAngle + fraction * totalArc; - - // Background arc segments per band - const bandArcs = useMemo(() => { - return BANDS.map((b) => { - const bStart = startAngle + ((b.range[0] - min) / (max - min)) * totalArc; - const bEnd = startAngle + ((Math.min(b.range[1], max) - min) / (max - min)) * totalArc; - return { ...b, path: describeArc(cx, cy, r, bStart, bEnd) }; - }); - }, [min, max]); - - // Active arc from start to current score - const activePath = describeArc(cx, cy, r, startAngle, scoreAngle); - return (