Files
MindSift/frontend/src/lib/__tests__/useConnectionState.test.tsx
T
sandrodachandClaude Opus 5.5 2b7356d043 feat(realtime): long polling for app-wide events behind proxies
When a proxy lets neither WebSocket nor SSE through, the app opens a
poll connection: the server buffers its events (an SSEClient without a
stream), each GET waits up to 25 s, and room joins work like for SSE.
Tasks and lists now update live there too, and note sync takes its
notifications from it instead of a second long poll. Idle connections
close after two minutes. The profile shows such a connection as limited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:50:27 +01:00

118 lines
4.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
let statusListener: ((connected: boolean, transport?: string) => void) | null = null;
let currentConnected = false;
vi.mock('../api', () => ({
onRealtimeStatus: (cb: (connected: boolean, transport?: string) => void) => {
statusListener = cb;
cb(currentConnected);
return () => (statusListener = null);
},
}));
import { useConnectionState, SERVER_OFFLINE_GRACE_MS, SERVER_PROBE_INTERVAL_MS } from '../useConnectionState';
function setDeviceOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
}
let serverAnswers = false;
const fetchMock = vi.fn(async () => {
if (!serverAnswers) throw new TypeError('Failed to fetch');
return { ok: true } as Response;
});
// Runs the timers and lets the health request settle
async function wait(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
describe('useConnectionState', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockClear();
currentConnected = false;
serverAnswers = false;
setDeviceOnline(true);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('stays online during the grace period at app start', async () => {
const { result } = renderHook(() => useConnectionState());
expect(result.current).toBe('online');
act(() => statusListener?.(true));
await wait(SERVER_OFFLINE_GRACE_MS + 100);
expect(result.current).toBe('online');
expect(fetchMock).not.toHaveBeenCalled();
});
it('turns offline once the server stays unreachable and back online on reconnect', async () => {
currentConnected = true;
const { result } = renderHook(() => useConnectionState());
act(() => statusListener?.(false));
expect(result.current).toBe('online');
await wait(SERVER_OFFLINE_GRACE_MS + 100);
expect(result.current).toBe('offline');
act(() => statusListener?.(true));
expect(result.current).toBe('online');
});
it('is limited when the server answers but no realtime connection gets through', async () => {
serverAnswers = true;
const { result } = renderHook(() => useConnectionState());
await wait(SERVER_OFFLINE_GRACE_MS + 100);
expect(result.current).toBe('limited');
expect(fetchMock).toHaveBeenCalledWith('/api/v1/health', expect.anything());
// Asked again while the connection is missing
serverAnswers = false;
await wait(SERVER_PROBE_INTERVAL_MS + 100);
expect(result.current).toBe('offline');
serverAnswers = true;
await wait(SERVER_PROBE_INTERVAL_MS + 100);
expect(result.current).toBe('limited');
act(() => statusListener?.(true));
expect(result.current).toBe('online');
const calls = fetchMock.mock.calls.length;
await wait(SERVER_PROBE_INTERVAL_MS * 2);
expect(fetchMock.mock.calls.length).toBe(calls);
});
it('is limited while changes only come by long polling', async () => {
const { result } = renderHook(() => useConnectionState());
act(() => statusListener?.(true, 'poll'));
expect(result.current).toBe('limited');
expect(fetchMock).not.toHaveBeenCalled();
act(() => statusListener?.(true, 'sse'));
expect(result.current).toBe('online');
});
it('repeated failed reconnects do not postpone the check', async () => {
currentConnected = true;
const { result } = renderHook(() => useConnectionState());
for (let i = 0; i < 4; i++) {
act(() => statusListener?.(false));
await wait(SERVER_OFFLINE_GRACE_MS / 2);
}
expect(result.current).toBe('offline');
});
it('is offline right away without device network', () => {
currentConnected = true;
const { result } = renderHook(() => useConnectionState());
act(() => {
setDeviceOnline(false);
statusListener?.(false);
});
expect(result.current).toBe('offline');
});
});