From ea5e4b9d95a4def08a5f22662b15c1a4be255bad Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:41:17 +0000 Subject: [PATCH 1/5] fix: prevent EventSource reconnect storm in useStreamEvents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a stable subscription key derived from sorted streamIds, subscribeToAll, and jwtToken so buildUrl/connect only change when the subscription actually changes — not on every render due to inline array literals or default [] identity. - Derive subscriptionKey as a stable memoized string - Clear pending reconnect timer before scheduling a new one - Cap reconnect attempts at 20 (configurable via constant) Fixes #850 --- .../src/__tests__/useStreamEvents.test.tsx | 52 ++++++++++++++++ frontend/src/hooks/useStreamEvents.ts | 59 +++++++++++++------ 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/frontend/src/__tests__/useStreamEvents.test.tsx b/frontend/src/__tests__/useStreamEvents.test.tsx index b28a3c9b..c62e0edb 100644 --- a/frontend/src/__tests__/useStreamEvents.test.tsx +++ b/frontend/src/__tests__/useStreamEvents.test.tsx @@ -9,6 +9,7 @@ type ErrorHandler = () => void; class MockEventSource { static instance: MockEventSource | null = null; + static instanceCount = 0; url: string; onopen: (() => void) | null = null; @@ -21,6 +22,7 @@ class MockEventSource { constructor(url: string) { this.url = url; MockEventSource.instance = this; + MockEventSource.instanceCount += 1; } addEventListener(type: string, handler: EventHandler) { @@ -66,6 +68,7 @@ class MockEventSource { describe('useStreamEvents', () => { beforeEach(() => { MockEventSource.instance = null; + MockEventSource.instanceCount = 0; vi.useFakeTimers(); }); @@ -200,4 +203,53 @@ describe('useStreamEvents', () => { expect(result.current.events).toHaveLength(types.length); }); + + it('creates only one EventSource across multiple re-renders and incoming events', () => { + const { result, rerender } = renderHook( + (opts: { streamIds: string[] } = { streamIds: ['1'] }) => + useStreamEvents({ ...opts, autoReconnect: false }), + ); + + const firstInstance = MockEventSource.instance; + + act(() => { firstInstance?.open(); }); + + // Simulate multiple re-renders with the same subscription (inline array) + rerender({ streamIds: ['1'] }); + rerender({ streamIds: ['1'] }); + rerender({ streamIds: ['1'] }); + + // Simulate incoming events causing re-renders of the consumer + act(() => { + MockEventSource.instance?.emit('stream.created', { i: 1 }); + MockEventSource.instance?.emit('stream.created', { i: 2 }); + MockEventSource.instance?.emit('stream.created', { i: 3 }); + }); + + expect(result.current.events).toHaveLength(3); + + // Re-render again after events + rerender({ streamIds: ['1'] }); + rerender({ streamIds: ['1'] }); + + expect(MockEventSource.instanceCount).toBe(1); + expect(MockEventSource.instance).toBe(firstInstance); + }); + + it('stops reconnecting after reaching the cap', () => { + renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: true, maxRetryDelay: 1000 }), + ); + + // Trigger errors repeatedly to consume reconnect attempts. + // The reconnect delay stays at 1000ms (capped by maxRetryDelay). + for (let i = 0; i < 25; i++) { + act(() => { MockEventSource.instance?.triggerError(); }); + act(() => { vi.advanceTimersByTime(2000); }); + } + + // 1 initial + 20 reconnect attempts = 21 instances max. + // After the 20th reconnect attempt, no more timers should fire. + expect(MockEventSource.instanceCount).toBeLessThanOrEqual(21); + }); }); diff --git a/frontend/src/hooks/useStreamEvents.ts b/frontend/src/hooks/useStreamEvents.ts index f782c158..66438f59 100644 --- a/frontend/src/hooks/useStreamEvents.ts +++ b/frontend/src/hooks/useStreamEvents.ts @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; interface StreamEvent { type: 'created' | 'topped_up' | 'withdrawn' | 'cancelled' | 'completed' | 'paused' | 'resumed'; @@ -23,12 +23,13 @@ interface UseStreamEventsReturn { clearEvents: () => void; } +const MAX_RECONNECT_ATTEMPTS = 20; + export function useStreamEvents( options: UseStreamEventsOptions = {} ): UseStreamEventsReturn { const { - streamIds = [], - // userPublicKeys = [], + streamIds: rawStreamIds = [], subscribeToAll = false, autoReconnect = true, maxRetryDelay = 30000, @@ -43,32 +44,43 @@ export function useStreamEvents( const eventSourceRef = useRef(null); const retryDelayRef = useRef(1000); const reconnectTimeoutRef = useRef | null>(null); + const reconnectAttemptsRef = useRef(0); const connectRef = useRef<() => void>(() => undefined); + const subscriptionKey = useMemo(() => { + const streams = [...rawStreamIds].sort().join(','); + return `${subscribeToAll ? 'all' : streams}|${jwtToken || ''}`; + }, [rawStreamIds, subscribeToAll, jwtToken]); + const buildUrl = useCallback(() => { const params = new URLSearchParams(); if (subscribeToAll) { params.append('all', 'true'); } else { - streamIds.forEach(id => params.append('streams', id)); + rawStreamIds.forEach(id => params.append('streams', id)); } - // Add JWT token to query string for authentication - // (EventSource doesn't support custom headers in browser) if (jwtToken) { params.append('token', jwtToken); } const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; return `${baseUrl}/v1/events/subscribe?${params}`; - }, [streamIds, subscribeToAll, jwtToken]); + // subscriptionKey captures all subscription parameters as a stable string + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscriptionKey]); const clearEvents = useCallback(() => { setEvents([]); }, []); const connect = useCallback(() => { + if (reconnectTimeoutRef.current !== null) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + const url = buildUrl(); const eventSource = new EventSource(url); eventSourceRef.current = eventSource; @@ -77,16 +89,16 @@ export function useStreamEvents( setConnected(true); setReconnecting(false); setError(null); - retryDelayRef.current = 1000; // Reset retry delay + retryDelayRef.current = 1000; + reconnectAttemptsRef.current = 0; }; - const handleEvent = (type: StreamEvent['type']) => (e: MessageEvent) => { try { const data = JSON.parse(e.data); setEvents((prev: StreamEvent[]) => [ { type, data, timestamp: Date.now() }, - ...prev.slice(0, 99), // Keep last 100 events + ...prev.slice(0, 99), ]); } catch { // Silently ignore malformed event messages @@ -108,13 +120,23 @@ export function useStreamEvents( if (autoReconnect) { setReconnecting(true); - reconnectTimeoutRef.current = setTimeout(() => { - connectRef.current(); - retryDelayRef.current = Math.min( - retryDelayRef.current * 2, - maxRetryDelay - ); - }, retryDelayRef.current); + + if (reconnectTimeoutRef.current !== null) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + reconnectAttemptsRef.current += 1; + + if (reconnectAttemptsRef.current <= MAX_RECONNECT_ATTEMPTS) { + reconnectTimeoutRef.current = setTimeout(() => { + connectRef.current(); + retryDelayRef.current = Math.min( + retryDelayRef.current * 2, + maxRetryDelay + ); + }, retryDelayRef.current); + } } }; }, [buildUrl, autoReconnect, maxRetryDelay]); @@ -131,8 +153,9 @@ export function useStreamEvents( eventSourceRef.current.close(); eventSourceRef.current = null; } - if (reconnectTimeoutRef.current) { + if (reconnectTimeoutRef.current !== null) { clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } }; }, [connect]); From e86ad9baba367d225902ac1bee3804a1243f06ff Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:19:41 +0000 Subject: [PATCH 2/5] fix: resolve 4 CI failures for PR #996 1. Backend CI Build: Fix TS2532 errors with proper null/undefined checks - events-wire-format.test.ts: Use nullish coalescing fallback for HANDLER_READ_FIELDS lookup - stream.validator.test.ts: Add expect(result.error).toBeDefined() before accessing issues array 2. Frontend CI Lint: Fix parsing errors and cleanup - Rename useIncomingStreams.test.ts to .tsx (contains JSX) - Fix malformed try/catch block indentation in dashboard.ts fetchStreams - Remove unused useDashboard import from dashboard-view.tsx - Remove 4 unused eslint-disable no-console directives from logger.ts 3. Soroban Contracts CI: Run cargo fmt --all (rustfmt) 4. Backend npm test: Fix i128 decode error in stream-lifecycle test - Root cause: nativeToScVal(BigInt) without explicit type creates non-i128 ScVal; fix uses scvI128 for BigInt override values --- backend/tests/events-wire-format.test.ts | 2 +- .../integration/stream-lifecycle.test.ts | 2 +- backend/tests/stream.validator.test.ts | 3 ++- contracts/stream_contract/src/test.rs | 11 ++++----- .../components/dashboard/dashboard-view.tsx | 1 - ...ms.test.ts => useIncomingStreams.test.tsx} | 0 frontend/src/lib/dashboard.ts | 24 +++++++++---------- frontend/src/lib/logger.ts | 8 +++---- 8 files changed, 23 insertions(+), 28 deletions(-) rename frontend/src/hooks/{useIncomingStreams.test.ts => useIncomingStreams.test.tsx} (100%) diff --git a/backend/tests/events-wire-format.test.ts b/backend/tests/events-wire-format.test.ts index e7cd12fb..acdacfd3 100644 --- a/backend/tests/events-wire-format.test.ts +++ b/backend/tests/events-wire-format.test.ts @@ -91,7 +91,7 @@ describe('event wire format', () => { expect(decodedKeys).toEqual([...allFields].sort()); - for (const field of HANDLER_READ_FIELDS[eventName]) { + for (const field of HANDLER_READ_FIELDS[eventName] ?? []) { expect(decoded).toHaveProperty(field); } }, diff --git a/backend/tests/integration/stream-lifecycle.test.ts b/backend/tests/integration/stream-lifecycle.test.ts index 104a4a63..22a2e062 100644 --- a/backend/tests/integration/stream-lifecycle.test.ts +++ b/backend/tests/integration/stream-lifecycle.test.ts @@ -88,7 +88,7 @@ function createStreamCreatedEvent( ]; const overrideEntries: [string, xdr.ScVal][] = Object.entries(overrides).map( - ([k, v]) => [k, nativeToScVal(v)], + ([k, v]) => [k, typeof v === 'bigint' ? scvI128(v) : nativeToScVal(v)], ); return { diff --git a/backend/tests/stream.validator.test.ts b/backend/tests/stream.validator.test.ts index f546a5d0..a3952e7c 100644 --- a/backend/tests/stream.validator.test.ts +++ b/backend/tests/stream.validator.test.ts @@ -42,7 +42,8 @@ describe('Stream Validator', () => { }; const result = createStreamSchema.safeParse(data); expect(result.success).toBe(false); - expect(result.error?.issues[0].message).toBe('Rate exceeds maximum allowed value'); + expect(result.error).toBeDefined(); + expect(result.error!.issues[0].message).toBe('Rate exceeds maximum allowed value'); }); it('should accept ratePerSecond at i128 max', () => { diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index 0621e4f3..fa3371d7 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -433,8 +433,8 @@ fn test_backdated_start_time_would_immediately_vest_full_amount() { // env.ledger().timestamp() — but it demonstrates the risk that would exist // if a caller-supplied start_time were ever added. let mut stream = client.get_stream(&stream_id).unwrap(); - stream.start_time = 0; // backdated far into the past - stream.last_update_time = 0; // sync anchor to match + stream.start_time = 0; // backdated far into the past + stream.last_update_time = 0; // sync anchor to match env.as_contract(&client.address, || { env.storage() .persistent() @@ -2708,11 +2708,8 @@ fn test_cancel_state_committed_before_transfers_prevents_double_cancel() { fn event_field_names(env: &Env, payload: &soroban_sdk::Val) -> std::vec::Vec { let map = soroban_sdk::Map::::try_from_val(env, payload) .expect("event data is not a Map"); - let mut names: std::vec::Vec = map - .keys() - .iter() - .map(|sym| sym.to_string()) - .collect(); + let mut names: std::vec::Vec = + map.keys().iter().map(|sym| sym.to_string()).collect(); names.sort(); names } diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 34568f84..63d337f4 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -18,7 +18,6 @@ import toast from "react-hot-toast"; import { getDashboardAnalytics, fetchDashboardData, - useDashboard, dashboardQueryKey, type DashboardSnapshot, type Stream, diff --git a/frontend/src/hooks/useIncomingStreams.test.ts b/frontend/src/hooks/useIncomingStreams.test.tsx similarity index 100% rename from frontend/src/hooks/useIncomingStreams.test.ts rename to frontend/src/hooks/useIncomingStreams.test.tsx diff --git a/frontend/src/lib/dashboard.ts b/frontend/src/lib/dashboard.ts index 39a8814f..303f1289 100644 --- a/frontend/src/lib/dashboard.ts +++ b/frontend/src/lib/dashboard.ts @@ -88,21 +88,19 @@ async function fetchStreams( for (const endpoint of endpoints) { try { const response = await fetch(`${endpoint}?${params.toString()}`, { signal }); - if (response.ok) { - const payload = (await response.json()) as - | BackendStream[] - | { data?: BackendStream[] }; - return Array.isArray(payload) ? payload : payload.data ?? []; - } - - if (response.status === 404) { - lastError = new Error(`Endpoint not found: ${endpoint}`); - continue; - } + if (response.ok) { + const payload = (await response.json()) as + | BackendStream[] + | { data?: BackendStream[] }; + return Array.isArray(payload) ? payload : payload.data ?? []; + } - lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`); - } + if (response.status === 404) { + lastError = new Error(`Endpoint not found: ${endpoint}`); + continue; + } + lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw err; diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts index 496bfae5..0d1ad792 100644 --- a/frontend/src/lib/logger.ts +++ b/frontend/src/lib/logger.ts @@ -2,16 +2,16 @@ const isDev = process.env.NODE_ENV !== "production"; export const logger = { debug: (...args: unknown[]) => { - if (isDev) console.debug(...args); // eslint-disable-line no-console + if (isDev) console.debug(...args); }, info: (...args: unknown[]) => { - if (isDev) console.info(...args); // eslint-disable-line no-console + if (isDev) console.info(...args); }, warn: (...args: unknown[]) => { - if (isDev) console.warn(...args); // eslint-disable-line no-console + if (isDev) console.warn(...args); }, // errors always surface, even in production error: (...args: unknown[]) => { - console.error(...args); // eslint-disable-line no-console + console.error(...args); }, }; From e79a8bdeb380038694a77f677479084e96ba4e54 Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:32:04 +0000 Subject: [PATCH 3/5] fix: resolve remaining TS2532 and lint errors - stream.validator.test.ts: assert issues.length > 0 before accessing [0] - useIncomingStreams.test.tsx: add eslint-disable for no-explicit-any in tests --- backend/tests/stream.validator.test.ts | 3 ++- frontend/src/hooks/useIncomingStreams.test.tsx | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/tests/stream.validator.test.ts b/backend/tests/stream.validator.test.ts index a3952e7c..f5552a2c 100644 --- a/backend/tests/stream.validator.test.ts +++ b/backend/tests/stream.validator.test.ts @@ -43,7 +43,8 @@ describe('Stream Validator', () => { const result = createStreamSchema.safeParse(data); expect(result.success).toBe(false); expect(result.error).toBeDefined(); - expect(result.error!.issues[0].message).toBe('Rate exceeds maximum allowed value'); + expect(result.error!.issues.length).toBeGreaterThan(0); + expect(result.error!.issues[0]!.message).toBe('Rate exceeds maximum allowed value'); }); it('should accept ratePerSecond at i128 max', () => { diff --git a/frontend/src/hooks/useIncomingStreams.test.tsx b/frontend/src/hooks/useIncomingStreams.test.tsx index 39a9b9d8..d78c2939 100644 --- a/frontend/src/hooks/useIncomingStreams.test.tsx +++ b/frontend/src/hooks/useIncomingStreams.test.tsx @@ -74,16 +74,20 @@ describe("useIncomingStreams hooks", () => { ); await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any result.current.mutateAsync({} as any) ).rejects.toThrow("Please connect your wallet first"); expect(withdrawFromStream).not.toHaveBeenCalled(); }); it("invalidates incomingStreamsQueryKey(publicKey) on success", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (withdrawFromStream as any).mockResolvedValue({ status: "success" }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (fetchIncomingStreams as any).mockResolvedValue([]); const { result } = renderHook( + // eslint-disable-next-line @typescript-eslint/no-explicit-any () => useWithdrawIncomingStream({} as any, "pubkey"), { wrapper } ); @@ -99,6 +103,7 @@ describe("useIncomingStreams hooks", () => { ratePerSecond: 1, isPaused: false, lastUpdateTime: Date.now() / 1000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); }); From 948f51c78b7f8c3887770585ebd0643270dfd9fe Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:04:02 +0000 Subject: [PATCH 4/5] fix: resolve test timeout and FK violations from CI - Frontend: Fix useIncomingStreams test timeout by mocking fetchIncomingStreams with matching stream data so the pollIndexerForWithdraw loop exits on the first attempt instead of timing out after 63s of exponential backoff. This test was previously skipped (`.ts` with JSX couldn't parse); the `.tsx` rename exposed a pre-existing mock bug. - Backend: Add fileParallelism: false to vitest config so integration tests sharing the same database don't clobber each other's cleanup steps (foreign key violations). --- backend/vitest.config.ts | 3 +++ .../src/hooks/useIncomingStreams.test.tsx | 20 ++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 6c2d6878..51a3a290 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -43,5 +43,8 @@ export default defineConfig({ // Run each test file in its own forked process so vi.mock() doesn't leak pool: 'forks', isolate: true, + // Run files sequentially so integration tests sharing the same DB + // don't clobber each other's cleanup (foreign key violations). + fileParallelism: false, }, }); diff --git a/frontend/src/hooks/useIncomingStreams.test.tsx b/frontend/src/hooks/useIncomingStreams.test.tsx index d78c2939..02861d3b 100644 --- a/frontend/src/hooks/useIncomingStreams.test.tsx +++ b/frontend/src/hooks/useIncomingStreams.test.tsx @@ -81,10 +81,14 @@ describe("useIncomingStreams hooks", () => { }); it("invalidates incomingStreamsQueryKey(publicKey) on success", async () => { + // Return a matching stream so pollIndexerForWithdraw exits on the first + // attempt (1 s delay) by finding updatedStream.withdrawn > oldWithdrawn. // eslint-disable-next-line @typescript-eslint/no-explicit-any (withdrawFromStream as any).mockResolvedValue({ status: "success" }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fetchIncomingStreams as any).mockResolvedValue([]); + (fetchIncomingStreams as any).mockResolvedValue([ + { streamId: 1, withdrawn: 100 }, + ]); const { result } = renderHook( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -92,7 +96,7 @@ describe("useIncomingStreams hooks", () => { { wrapper } ); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const setQueryDataSpy = vi.spyOn(queryClient, "setQueryData"); await act(async () => { await result.current.mutateAsync({ @@ -107,12 +111,14 @@ describe("useIncomingStreams hooks", () => { } as any); }); - // Wait for pollIndexerForWithdraw to complete and call invalidateQueries + // Poll should find the updated stream (withdrawn 100 > 0) and call + // setQueryData after ~1 s of simulated delay. await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: incomingStreamsQueryKey("pubkey"), - }); - }, { timeout: 10000 }); + expect(setQueryDataSpy).toHaveBeenCalledWith( + incomingStreamsQueryKey("pubkey"), + expect.any(Array), + ); + }, { timeout: 5000 }); }); }); }); From 98dd23b0151fa88293df264ae19d4cac62ec40b3 Mon Sep 17 00:00:00 2001 From: Hollujay <165713167+Hollujay@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:06:59 +0000 Subject: [PATCH 5/5] fix: robust test fix + CI serialization for integration tests - Frontend: Clear setQueryData spy after mutateAsync so the assertion only catches the poll's call, not any optimistic onMutate update. - CI: Add --no-file-parallelism to Backend CI and pr-test-gate workflows instead of baking it into vitest.config.ts so local devs can still run fast parallel tests. --- .github/workflows/ci.yml | 2 +- .github/workflows/pr-test-gate.yml | 2 +- backend/vitest.config.ts | 3 --- frontend/src/hooks/useIncomingStreams.test.tsx | 4 ++++ 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14401e35..4f2decfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - name: Run Backend Tests run: | ls -la src/generated/prisma - npx vitest run --coverage --reporter=basic + npx vitest run --coverage --reporter=basic --no-file-parallelism working-directory: backend env: DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test diff --git a/.github/workflows/pr-test-gate.yml b/.github/workflows/pr-test-gate.yml index b2471627..843cb9fe 100644 --- a/.github/workflows/pr-test-gate.yml +++ b/.github/workflows/pr-test-gate.yml @@ -65,7 +65,7 @@ jobs: DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test - name: Run backend tests - run: npm test + run: npm test -- --no-file-parallelism working-directory: backend env: DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 51a3a290..6c2d6878 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -43,8 +43,5 @@ export default defineConfig({ // Run each test file in its own forked process so vi.mock() doesn't leak pool: 'forks', isolate: true, - // Run files sequentially so integration tests sharing the same DB - // don't clobber each other's cleanup (foreign key violations). - fileParallelism: false, }, }); diff --git a/frontend/src/hooks/useIncomingStreams.test.tsx b/frontend/src/hooks/useIncomingStreams.test.tsx index 02861d3b..1583b241 100644 --- a/frontend/src/hooks/useIncomingStreams.test.tsx +++ b/frontend/src/hooks/useIncomingStreams.test.tsx @@ -111,6 +111,10 @@ describe("useIncomingStreams hooks", () => { } as any); }); + // Clear any calls made during mutation (e.g. optimistic update in + // onMutate) so we only assert on the poll's setQueryData call. + setQueryDataSpy.mockClear(); + // Poll should find the updated stream (withdrawn 100 > 0) and call // setQueryData after ~1 s of simulated delay. await waitFor(() => {