Skip to content

Commit fe29937

Browse files
authored
Merge pull request #187 from benfoster-dev/feat/test-coverage-91-92
Bundle analysis, RPC de-duplication, and test coverage (#89, #90, #91, #92)
2 parents 7a3911c + 401f049 commit fe29937

8 files changed

Lines changed: 414 additions & 21 deletions

next.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NextConfig } from "next";
2+
import withBundleAnalyzer from "@next/bundle-analyzer";
23

34
const raw = process.env.BASE_PATH?.trim() ?? "";
45
const basePath = raw.startsWith("/") ? raw : raw ? `/${raw}` : "";
@@ -46,5 +47,5 @@ const nextConfig: NextConfig = {
4647
...(basePath ? { basePath, assetPrefix: basePath } : {}),
4748
};
4849

49-
export default nextConfig;
50+
export default withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(nextConfig);
5051
export { CSP_POLICY };

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"lint": "next lint",
1414
"test": "vitest run",
1515
"test:coverage": "vitest run --coverage --coverage.include src/lib/soroban.ts",
16+
"build:analyze": "ANALYZE=true next build",
1617
"playwright": "playwright test --config e2e/playwright.config.ts",
1718
"contributors:sync": "node scripts/sync-lernza-contributors.mjs",
1819
"contributors:github-insights": "node scripts/record-lernza-github-coauthors.mjs"
@@ -31,12 +32,11 @@
3132
"react": "^19.0.0",
3233
"react-dom": "^19.0.0",
3334
"recharts": "^3.9.0",
34-
"soroban-client": "^1.0.1",
35-
"stellar-plus": "^0.14.4",
3635
"zustand": "^5.0.14"
3736
},
3837
"devDependencies": {
3938
"@axe-core/playwright": "^4.12.1",
39+
"@next/bundle-analyzer": "^15.5.18",
4040
"@chakra-ui/icons": "^2.2.4",
4141
"@playwright/test": "^1.61.1",
4242
"@tailwindcss/postcss": "^4.3.2",

src/hooks/useSorobanEvents.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,4 +392,121 @@ describe("useSorobanEvents", () => {
392392
expect(mockRpc.getLatestLedger).not.toHaveBeenCalled();
393393
expect(mockRpc.getEvents).not.toHaveBeenCalled();
394394
});
395+
396+
it("re-anchors startLedger via getLatestLedger when getEvents throws a retention-window error", async () => {
397+
const retentionError = new Error(
398+
"start is before oldest ledger 2000"
399+
);
400+
401+
const mockRpc: SorobanEventsRpc = {
402+
getLatestLedger: vi.fn().mockResolvedValue({ sequence: 1000 }),
403+
getEvents: vi.fn().mockRejectedValue(retentionError),
404+
};
405+
406+
const wrapper = ({ children }: { children: React.ReactNode }) =>
407+
createElement(QueryClientProvider, { client: queryClient }, children);
408+
409+
renderHook(
410+
() => useSorobanEvents([TEST_CONTRACT_ID], ["lock_assets"], mockRpc),
411+
{ wrapper }
412+
);
413+
414+
// init() resolves getLatestLedger → startLedgerRef = 1000
415+
await vi.advanceTimersByTimeAsync(0);
416+
expect(mockRpc.getLatestLedger).toHaveBeenCalledTimes(1);
417+
418+
// First poll tick → getEvents fails → catch block must re-anchor
419+
await vi.advanceTimersByTimeAsync(5000);
420+
421+
// NOTE: the catch block currently swallows the error without re-anchoring.
422+
// Once the catch block is fixed to call getLatestLedger on retention-window
423+
// errors, this assertion will pass. Until then it documents the expected
424+
// contract: startLedgerRef must be refreshed so the next tick doesn't
425+
// re-submit the same stale startLedger and loop forever.
426+
expect(mockRpc.getLatestLedger).toHaveBeenCalledTimes(2);
427+
expect(mockRpc.getEvents).toHaveBeenCalledTimes(1);
428+
});
429+
430+
it("resumes event processing after recovery from a transient failure", async () => {
431+
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
432+
433+
const lockEvent = {
434+
inSuccessfulContractCall: true,
435+
topic: [
436+
xdr.ScVal.scvSymbol("lock_assets"),
437+
addrScVal(TEST_PUBLIC_KEY),
438+
],
439+
contractId: TEST_CONTRACT_ID,
440+
value: xdr.ScVal.scvVoid(),
441+
txHash: "deadbeef",
442+
ledger: 2001,
443+
ledgerClosedAt: new Date().toISOString(),
444+
pagingToken: "2001-0-0",
445+
id: "2001-0-0",
446+
type: "contract",
447+
};
448+
449+
const mockRpc: SorobanEventsRpc = {
450+
getLatestLedger: vi
451+
.fn()
452+
.mockResolvedValueOnce({ sequence: 1000 })
453+
.mockResolvedValueOnce({ sequence: 2000 }),
454+
getEvents: vi
455+
.fn()
456+
.mockRejectedValueOnce(new Error("start is before oldest ledger"))
457+
.mockResolvedValueOnce({
458+
events: [lockEvent],
459+
latestLedger: 2001,
460+
}),
461+
};
462+
463+
const wrapper = ({ children }: { children: React.ReactNode }) =>
464+
createElement(QueryClientProvider, { client: queryClient }, children);
465+
466+
renderHook(
467+
() => useSorobanEvents([TEST_CONTRACT_ID], ["lock_assets"], mockRpc),
468+
{ wrapper }
469+
);
470+
471+
// init
472+
await vi.advanceTimersByTimeAsync(0);
473+
474+
// Tick 1 → fails, catch re-anchors via getLatestLedger
475+
await vi.advanceTimersByTimeAsync(5000);
476+
expect(mockRpc.getEvents).toHaveBeenCalledTimes(1);
477+
478+
// Tick 2 → succeeds with fresh events after re-anchor
479+
await vi.advanceTimersByTimeAsync(5000);
480+
expect(mockRpc.getEvents).toHaveBeenCalledTimes(2);
481+
482+
expect(invalidateQueries).toHaveBeenCalledWith({
483+
queryKey: [QUERY_KEYS.USER_POSITION],
484+
});
485+
});
486+
487+
it("does not re-anchor on generic transient errors (only retention-window errors trigger re-anchoring)", async () => {
488+
const genericError = new Error("network timeout");
489+
490+
const mockRpc: SorobanEventsRpc = {
491+
getLatestLedger: vi.fn().mockResolvedValue({ sequence: 1000 }),
492+
getEvents: vi.fn().mockRejectedValue(genericError),
493+
};
494+
495+
const wrapper = ({ children }: { children: React.ReactNode }) =>
496+
createElement(QueryClientProvider, { client: queryClient }, children);
497+
498+
renderHook(
499+
() => useSorobanEvents([TEST_CONTRACT_ID], ["lock_assets"], mockRpc),
500+
{ wrapper }
501+
);
502+
503+
await vi.advanceTimersByTimeAsync(0);
504+
505+
// Tick 1 → generic error
506+
await vi.advanceTimersByTimeAsync(5000);
507+
508+
// getLatestLedger was only called once during init, not in the catch block
509+
expect(mockRpc.getLatestLedger).toHaveBeenCalledTimes(1);
510+
expect(mockRpc.getEvents).toHaveBeenCalledTimes(1);
511+
});
395512
});

src/hooks/useSorobanQuery.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,26 @@ import { waitFor } from "@testing-library/react";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { renderHook, act } from "@/test/renderHook";
66
import { sorobanService } from "@/lib/soroban";
7-
import { usePoolDepositors, useLockAssetsFeePreview } from "./useSorobanQuery";
7+
import {
8+
usePoolDepositors,
9+
useLockAssetsFeePreview,
10+
useSetBoost,
11+
} from "./useSorobanQuery";
812

913
vi.mock("@/lib/soroban", async (importOriginal) => {
1014
const actual = await importOriginal<typeof import("@/lib/soroban")>();
1115
return { ...actual, simulateLockAssets: vi.fn() };
1216
});
1317

18+
vi.mock("@/context/StellarWalletContext", () => ({
19+
useStellarWallet: vi.fn(),
20+
}));
21+
22+
vi.mock("@chakra-ui/react", async (importOriginal) => {
23+
const actual = await importOriginal<typeof import("@chakra-ui/react")>();
24+
return { ...actual, useToast: vi.fn() };
25+
});
26+
1427
const { simulateLockAssets } = await import("@/lib/soroban");
1528
const simulateLockAssetsMock = vi.mocked(simulateLockAssets);
1629

@@ -232,3 +245,172 @@ describe("useLockAssetsFeePreview (#134)", () => {
232245
expect(result.current.data?.feePreview).toBe("second");
233246
});
234247
});
248+
249+
describe("useSetBoost (#92)", () => {
250+
const TEST_PUBLIC_KEY =
251+
"GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN";
252+
const POOL_ID = "pool-xlm";
253+
let queryClient: QueryClient;
254+
let toastMock: ReturnType<typeof vi.fn>;
255+
256+
beforeEach(() => {
257+
queryClient = new QueryClient({
258+
defaultOptions: { queries: { retry: false } },
259+
});
260+
});
261+
262+
function boostWrapper({ children }: { children: ReactNode }) {
263+
return createElement(QueryClientProvider, { client: queryClient }, children);
264+
}
265+
266+
beforeEach(async () => {
267+
const { useStellarWallet } = await import(
268+
"@/context/StellarWalletContext"
269+
);
270+
vi.mocked(useStellarWallet).mockReturnValue({
271+
publicKey: TEST_PUBLIC_KEY,
272+
isConnected: true,
273+
walletApi: { signTransaction: vi.fn().mockResolvedValue("signed-xdr") },
274+
connect: vi.fn(),
275+
disconnect: vi.fn(),
276+
});
277+
278+
const { useToast } = await import("@chakra-ui/react");
279+
toastMock = vi.fn();
280+
vi.mocked(useToast).mockReturnValue(toastMock);
281+
});
282+
283+
it("calls sorobanService.setBoost with correct arguments", async () => {
284+
const spy = vi
285+
.spyOn(sorobanService, "setBoost")
286+
.mockResolvedValue({ success: true, transactionHash: "txhash" });
287+
288+
const { result } = renderHook(() => useSetBoost(), {
289+
wrapper: boostWrapper,
290+
});
291+
292+
await act(async () => {
293+
result.current.mutate({ poolId: POOL_ID, allocationPercentage: 50 });
294+
});
295+
296+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
297+
298+
expect(spy).toHaveBeenCalledWith(
299+
POOL_ID,
300+
TEST_PUBLIC_KEY,
301+
50,
302+
expect.objectContaining({ signTransaction: expect.any(Function) }),
303+
);
304+
});
305+
306+
it("shows success toast and invalidates queries on success", async () => {
307+
vi.spyOn(sorobanService, "setBoost").mockResolvedValue({
308+
success: true,
309+
transactionHash: "txhash123",
310+
});
311+
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
312+
313+
const { result } = renderHook(() => useSetBoost(), {
314+
wrapper: boostWrapper,
315+
});
316+
317+
await act(async () => {
318+
result.current.mutate({ poolId: POOL_ID, allocationPercentage: 75 });
319+
});
320+
321+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
322+
323+
expect(toastMock).toHaveBeenCalledWith(
324+
expect.objectContaining({
325+
title: "Boost Configuration Updated",
326+
description: "Boost set to 75%",
327+
status: "success",
328+
}),
329+
);
330+
331+
expect(invalidateSpy).toHaveBeenCalledWith({
332+
queryKey: ["userPosition", POOL_ID],
333+
});
334+
expect(invalidateSpy).toHaveBeenCalledWith({
335+
queryKey: ["userCredits", POOL_ID],
336+
});
337+
expect(invalidateSpy).toHaveBeenCalledWith({
338+
queryKey: ["boostConfig", POOL_ID],
339+
});
340+
});
341+
342+
it("shows error toast when setBoost fails", async () => {
343+
vi.spyOn(sorobanService, "setBoost").mockResolvedValue({
344+
success: false,
345+
error: "Simulation failed",
346+
});
347+
348+
const { result } = renderHook(() => useSetBoost(), {
349+
wrapper: boostWrapper,
350+
});
351+
352+
await act(async () => {
353+
result.current.mutate({ poolId: POOL_ID, allocationPercentage: 50 });
354+
});
355+
356+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
357+
358+
expect(toastMock).toHaveBeenCalledWith(
359+
expect.objectContaining({
360+
title: "Boost Configuration Failed",
361+
status: "error",
362+
}),
363+
);
364+
});
365+
366+
it("shows error toast when wallet is not connected", async () => {
367+
const { useStellarWallet } = await import(
368+
"@/context/StellarWalletContext"
369+
);
370+
vi.mocked(useStellarWallet).mockReturnValue({
371+
publicKey: null,
372+
isConnected: false,
373+
walletApi: null,
374+
connect: vi.fn(),
375+
disconnect: vi.fn(),
376+
});
377+
378+
const { result } = renderHook(() => useSetBoost(), {
379+
wrapper: boostWrapper,
380+
});
381+
382+
await act(async () => {
383+
result.current.mutate({ poolId: POOL_ID, allocationPercentage: 50 });
384+
});
385+
386+
await waitFor(() => expect(result.current.isError).toBe(true));
387+
388+
expect(result.current.error?.message).toBe(
389+
"Wallet not connected",
390+
);
391+
});
392+
393+
it("shows error toast when service throws", async () => {
394+
vi.spyOn(sorobanService, "setBoost").mockRejectedValue(
395+
new Error("Network error"),
396+
);
397+
398+
const { result } = renderHook(() => useSetBoost(), {
399+
wrapper: boostWrapper,
400+
});
401+
402+
await act(async () => {
403+
result.current.mutate({ poolId: POOL_ID, allocationPercentage: 50 });
404+
});
405+
406+
await waitFor(() => expect(result.current.isError).toBe(true));
407+
408+
expect(toastMock).toHaveBeenCalledWith(
409+
expect.objectContaining({
410+
title: "Transaction Error",
411+
description: "Network error",
412+
status: "error",
413+
}),
414+
);
415+
});
416+
});

src/lib/soroban.feebump.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ vi.mock('@/config', () => ({
1414
horizonUrl: 'https://horizon-testnet.stellar.org',
1515
networkPassphrase: 'Test SDF Network ; September 2015',
1616
sorobanRpcUrl: 'https://soroban-testnet.stellar.org',
17+
simulationAccount: 'GBQ3WPTHKJ5XKWLOKUZJLZL2GVXR6RWQCXUVDQZWM7Q2YNLDRVGM5ZWJ',
18+
stellarNetwork: 'TESTNET',
1719
}));
1820

1921
const USER_PUBKEY = StrKey.encodeEd25519PublicKey(new Uint8Array(32).fill(1));

0 commit comments

Comments
 (0)