Skip to content
Open
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
70 changes: 32 additions & 38 deletions backend/src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockGetScoreConfig = jest.fn(() => ({
repaymentDelta: 15,
defaultPenalty: 50,
}));
const mockUpdateUserScoresBulk = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);

jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
Expand All @@ -32,6 +33,10 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({
sorobanService: { getScoreConfig: mockGetScoreConfig },
}));

jest.unstable_mockModule("../services/scoresService.js", () => ({
updateUserScoresBulk: mockUpdateUserScoresBulk,
}));

jest.unstable_mockModule("../utils/logger.js", () => ({
default: {
info: jest.fn(),
Expand Down Expand Up @@ -126,7 +131,6 @@ describe("EventIndexer", () => {
const borrowerRepaid = makeAddress();
const borrowerDefaulted = makeAddress();
const insertedLoanEvents: unknown[][] = [];
const scoreUpdates: unknown[][] = [];

mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => {
if (sql === "BEGIN" || sql === "COMMIT") {
Expand All @@ -138,11 +142,6 @@ describe("EventIndexer", () => {
return { rows: [{ event_id: params[0] }], rowCount: 1 };
}

if (sql.includes("INSERT INTO scores")) {
scoreUpdates.push(params);
return { rows: [], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

Expand Down Expand Up @@ -208,10 +207,13 @@ describe("EventIndexer", () => {
expect(insertedLoanEvents[3]?.[2]).toBe(9);
expect(insertedLoanEvents[3]?.[3]).toBe(borrowerDefaulted);

expect(scoreUpdates).toEqual([
[borrowerRepaid, 515, 15],
[borrowerDefaulted, 450, -50],
]);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(
new Map([
[borrowerRepaid, 15],
[borrowerDefaulted, -50],
]),
);
expect(mockGetScoreConfig).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(mockBroadcast).toHaveBeenCalledTimes(4);
Expand All @@ -236,10 +238,6 @@ describe("EventIndexer", () => {
};
}

if (sql.includes("INSERT INTO scores")) {
return { rows: [], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

Expand Down Expand Up @@ -269,6 +267,8 @@ describe("EventIndexer", () => {
expect(mockBroadcast).toHaveBeenCalledTimes(1);
expect(mockCreateNotification).toHaveBeenCalledTimes(1);
expect(mockGetScoreConfig).toHaveBeenCalledTimes(1);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1);
expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(new Map([[borrower, 15]]));
});

it("initializes missing indexer state and persists the last indexed ledger during polling", async () => {
Expand All @@ -279,47 +279,41 @@ describe("EventIndexer", () => {
return { rows: [], rowCount: 0 };
}

if (sql.includes("INSERT INTO indexer_state")) {
stateWrites.push(Number(params[0] ?? 0));
return { rows: [], rowCount: 1 };
if (sql.includes("INSERT INTO indexer_state") && params.length === 0) {
return { rows: [{ last_indexed_ledger: 0 }], rowCount: 1 };
}

if (sql.includes("UPDATE indexer_state")) {
stateWrites.push(Number(params[0]));
return { rows: [], rowCount: 1 };
}

if (sql === "BEGIN" || sql === "COMMIT") {
return { rows: [], rowCount: 0 };
}

if (sql.includes("INSERT INTO loan_events")) {
return { rows: [{ event_id: params[0] }], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

const indexer = new EventIndexer({
rpcUrl: "https://rpc.test",
contractId: "CINDEXERTEST",
pollIntervalMs: 5,
batchSize: 50,
});

(indexer as { running: boolean }).running = true;
(indexer as {
rpc: {
getLatestLedger: () => Promise<{ sequence: number }>;
getEvents: () => Promise<{ events: unknown[] }>;
};
}).rpc = {
getLatestLedger: jest.fn().mockResolvedValue({ sequence: 15 }),
getEvents: jest.fn().mockResolvedValue({
events: [makeRawEvent({ id: "evt-poll", ledger: 15, type: "LoanRequested" })],
}),
const processChunk = jest
.spyOn(indexer as unknown as { processChunk: (start: number, end: number) => Promise<unknown> }, "processChunk")
.mockResolvedValue({
lastProcessedLedger: 25,
fetchedEvents: 0,
insertedEvents: 0,
});

(indexer as { rpc: { getLatestLedger: () => Promise<{ sequence: number }> } }).rpc = {
getLatestLedger: jest.fn().mockResolvedValue({ sequence: 25 }),
};

await (indexer as { pollOnce: () => Promise<void> }).pollOnce();
await indexer.start();
indexer.stop();

expect(stateWrites).toEqual([0, 15]);
expect(processChunk).toHaveBeenCalledWith(1, 25);
expect(stateWrites).toContain(25);
});
});
76 changes: 76 additions & 0 deletions frontend/src/app/components/global_ui/NetworkStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";

import { useEffect, useState } from "react";
import { WifiOff, RefreshCw, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";

/**
* Global banner to notify users of network connectivity issues.
* Provides a clear recovery path and explains that cached data is being used.
*/
export function NetworkStatusBanner() {
const [isOffline, setIsOffline] = useState(false);
const [isVisible, setIsVisible] = useState(true);

useEffect(() => {
const handleOnline = () => {
setIsOffline(false);
setIsVisible(true);
};
const handleOffline = () => {
setIsOffline(true);
setIsVisible(true);
};

window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);

// Initial check on mount
if (typeof navigator !== "undefined") {
setIsOffline(!navigator.onLine);
}

return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);

if (!isOffline || !isVisible) return null;

return (
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="sticky top-0 z-[100] w-full border-b border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/30 dark:bg-amber-950/40 dark:text-amber-200"
>
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2 text-sm">
<div className="flex items-center gap-2 font-medium">
<WifiOff className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span>
You are currently offline. Displaying cached data. Some features may be limited.
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => window.location.reload()}
className="inline-flex items-center gap-1 rounded-lg bg-amber-100 px-2 py-1 text-xs font-semibold text-amber-700 hover:bg-amber-200 transition-colors dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900"
>
<RefreshCw className="h-3 w-3" />
Reconnect
</button>
<button
onClick={() => setIsVisible(false)}
className="rounded-full p-1 hover:bg-amber-100 dark:hover:bg-amber-900"
aria-label="Close notification"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
24 changes: 18 additions & 6 deletions frontend/src/app/components/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,24 @@ export function QueryProvider({ children }: QueryProviderProps) {
new QueryClient({
defaultOptions: {
queries: {
// Data is considered fresh for 60 seconds — avoids unnecessary refetches
staleTime: 60 * 1000,
// Retry failed requests once before showing an error
retry: 1,
// Refetch when the browser window regains focus
refetchOnWindowFocus: true,
staleTime: 5 * 60 * 1000, // 5 minutes: UI stays responsive with cached data
gcTime: 30 * 60 * 1000, // 30 minutes: Keep data in memory even when inactive
retry: (failureCount, error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
// Safe check for browser environment
const isOffline = typeof window !== 'undefined' && !navigator.onLine;

// Always retry on network errors or when offline
if (isOffline || errorMessage.includes('Network') || errorMessage.includes('fetch')) {
return true;
}

// Standard retry for other transient errors (up to 3 times)
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
refetchOnWindowFocus: true, // Re-validate data when user returns to tab
refetchOnReconnect: 'always', // Force retry when connection returns
},
mutations: {
// Retry failed mutations once
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Toaster } from "./components/ui/Toaster";
import { LevelUpModal } from "./components/gamification/LevelUpModal";
import { GlobalXPGain } from "./components/global_ui/GlobalXPGain";
import { ErrorBoundary } from "./components/global_ui/ErrorBoundary";
import { NetworkStatusBanner } from "./components/global_ui/NetworkStatusBanner";
import { NextIntlClientProvider } from "next-intl";
import { THEME_STORAGE_KEY } from "./lib/theme";

Expand Down Expand Up @@ -46,6 +47,7 @@ export default async function RootLayout({
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<NextIntlClientProvider locale="en" messages={messages}>
<QueryProvider>
<NetworkStatusBanner />
<WalletProvider>
<DashboardShell>
<ErrorBoundary scope="active page" variant="section">
Expand Down
Loading