style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+169 -177
View File
@@ -11,11 +11,11 @@
* Run: pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { ReactNode } from 'react'
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ---------------------------------------------------------------------------
// Mock EventSource
@@ -25,60 +25,60 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// ---------------------------------------------------------------------------
type MockEventSourceInstance = {
url: string
withCredentials: boolean
readyState: number
onopen: ((ev: Event) => void) | null
onerror: ((ev: Event) => void) | null
listeners: Map<string, Array<(ev: MessageEvent) => void>>
addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void
close: () => void
url: string;
withCredentials: boolean;
readyState: number;
onopen: ((ev: Event) => void) | null;
onerror: ((ev: Event) => void) | null;
listeners: Map<string, Array<(ev: MessageEvent) => void>>;
addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void;
close: () => void;
// Test helpers — trigger events
_triggerOpen: () => void
_triggerError: () => void
_triggerMessage: (type: string, data: unknown) => void
}
_triggerOpen: () => void;
_triggerError: () => void;
_triggerMessage: (type: string, data: unknown) => void;
};
let mockInstances: MockEventSourceInstance[] = []
let mockInstances: MockEventSourceInstance[] = [];
class MockEventSource {
url: string
withCredentials: boolean
readyState: number = 0
onopen: ((ev: Event) => void) | null = null
onerror: ((ev: Event) => void) | null = null
listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map()
closeCalled = false
url: string;
withCredentials: boolean;
readyState: number = 0;
onopen: ((ev: Event) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map();
closeCalled = false;
constructor(url: string, init?: { withCredentials?: boolean }) {
this.url = url
this.withCredentials = init?.withCredentials ?? false
mockInstances.push(this)
this.url = url;
this.withCredentials = init?.withCredentials ?? false;
mockInstances.push(this);
}
addEventListener(type: string, handler: (ev: MessageEvent) => void) {
const existing = this.listeners.get(type) ?? []
this.listeners.set(type, [...existing, handler])
const existing = this.listeners.get(type) ?? [];
this.listeners.set(type, [...existing, handler]);
}
close() {
this.closeCalled = true
this.readyState = 2 // CLOSED
this.closeCalled = true;
this.readyState = 2; // CLOSED
}
_triggerOpen() {
this.readyState = 1 // OPEN
this.onopen?.({} as Event)
this.readyState = 1; // OPEN
this.onopen?.({} as Event);
}
_triggerError() {
this.onerror?.({} as Event)
this.onerror?.({} as Event);
}
_triggerMessage(type: string, data: unknown) {
const handlers = this.listeners.get(type) ?? []
const event = new MessageEvent(type, { data: JSON.stringify(data) })
handlers.forEach((h) => h(event))
const handlers = this.listeners.get(type) ?? [];
const event = new MessageEvent(type, { data: JSON.stringify(data) });
handlers.forEach((h) => h(event));
}
}
@@ -88,22 +88,22 @@ class MockEventSource {
function makeWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children)
}
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
}
beforeEach(() => {
mockInstances = []
vi.useFakeTimers()
mockInstances = [];
vi.useFakeTimers();
// Replace global EventSource with mock
vi.stubGlobal('EventSource', MockEventSource)
})
vi.stubGlobal('EventSource', MockEventSource);
});
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
mockInstances = []
})
vi.useRealTimers();
vi.unstubAllGlobals();
mockInstances = [];
});
// ---------------------------------------------------------------------------
// Tests
@@ -111,222 +111,214 @@ afterEach(() => {
describe('useListSSE — D-11 bounded backoff', () => {
it('reports connected and resets attempt counter when EventSource fires open', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
);
// Trigger open on the created EventSource
const es = mockInstances[0] as unknown as { _triggerOpen: () => void }
const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
act(() => {
es._triggerOpen()
})
es._triggerOpen();
});
expect(stateChanges).toContain('connected')
unmount()
})
expect(stateChanges).toContain('connected');
unmount();
});
it('invalidates [list, listId] query on successful open (D-10 full refetch on reconnect)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { unmount } = renderHook(
() => useListSSE({ listId: 42, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 42, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
act(() => {
const es = mockInstances[0] as unknown as { _triggerOpen: () => void }
es._triggerOpen()
})
const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
es._triggerOpen();
});
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['list', 42] }),
)
unmount()
})
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['list', 42] }));
unmount();
});
it('invalidates [list, listId] when a list-change SSE event is received', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { unmount } = renderHook(
() => useListSSE({ listId: 7, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 7, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
act(() => {
const es = mockInstances[0] as unknown as {
_triggerOpen: () => void
_triggerMessage: (type: string, data: unknown) => void
}
es._triggerOpen()
es._triggerMessage('item:added', { type: 'item:added', listId: 7 })
})
_triggerOpen: () => void;
_triggerMessage: (type: string, data: unknown) => void;
};
es._triggerOpen();
es._triggerMessage('item:added', { type: 'item:added', listId: 7 });
});
const calls = invalidateSpy.mock.calls
const calls = invalidateSpy.mock.calls;
const hasListQuery = calls.some((args) => {
const opts = args[0] as { queryKey?: unknown[] }
return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7])
})
expect(hasListQuery).toBe(true)
unmount()
})
const opts = args[0] as { queryKey?: unknown[] };
return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7]);
});
expect(hasListQuery).toBe(true);
unmount();
});
it('transitions to reconnecting on first error and schedules retry after 250ms (D-11 step 0)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
);
act(() => {
mockInstances[0]._triggerError()
})
mockInstances[0]._triggerError();
});
// Should be reconnecting (not disconnected — still have attempts left)
expect(stateChanges).toContain('reconnecting')
expect(stateChanges).toContain('reconnecting');
// Advance 250ms — a new EventSource should be created
act(() => {
vi.advanceTimersByTime(250)
})
vi.advanceTimersByTime(250);
});
expect(mockInstances.length).toBeGreaterThanOrEqual(2) // reconnected
unmount()
})
expect(mockInstances.length).toBeGreaterThanOrEqual(2); // reconnected
unmount();
});
it('stops retrying after MAX_ATTEMPTS and transitions to disconnected (D-11 backoff exhausted)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
wrapper: makeWrapper(queryClient),
});
// Exhaust all 6 backoff steps: 250→500→1000→2000→4000→8000ms
const backoffSteps = [250, 500, 1000, 2000, 4000, 8000]
const backoffSteps = [250, 500, 1000, 2000, 4000, 8000];
for (const delay of backoffSteps) {
// Trigger error on the latest EventSource instance
act(() => {
const es = mockInstances[mockInstances.length - 1]
es._triggerError()
})
const es = mockInstances[mockInstances.length - 1];
es._triggerError();
});
// Advance to the next backoff delay to allow next connection attempt
act(() => {
vi.advanceTimersByTime(delay)
})
vi.advanceTimersByTime(delay);
});
}
// After exhausting all attempts, trigger error on last instance
act(() => {
const es = mockInstances[mockInstances.length - 1]
es._triggerError()
})
const es = mockInstances[mockInstances.length - 1];
es._triggerError();
});
// State must be 'disconnected' — no more retries
const lastState = stateChanges[stateChanges.length - 1]
expect(lastState).toBe('disconnected')
const lastState = stateChanges[stateChanges.length - 1];
expect(lastState).toBe('disconnected');
// No additional timer scheduled — advancing time should not create a new instance
const instanceCount = mockInstances.length
const instanceCount = mockInstances.length;
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(mockInstances.length).toBe(instanceCount) // no new connection attempt
})
vi.advanceTimersByTime(30_000);
});
expect(mockInstances.length).toBe(instanceCount); // no new connection attempt
});
it('resets backoff counter on successful reconnect (D-11 — counter reset)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
wrapper: makeWrapper(queryClient),
});
// Fail once
act(() => {
mockInstances[0]._triggerError()
})
mockInstances[0]._triggerError();
});
act(() => {
vi.advanceTimersByTime(250) // first backoff
})
vi.advanceTimersByTime(250); // first backoff
});
// Reconnect successfully
act(() => {
const latest = mockInstances[mockInstances.length - 1]
latest._triggerOpen()
})
const latest = mockInstances[mockInstances.length - 1];
latest._triggerOpen();
});
expect(stateChanges).toContain('connected')
expect(stateChanges).toContain('connected');
// The attempt counter should be reset. Fail again — should reconnect, not give up.
const statesBefore = stateChanges.length
const statesBefore = stateChanges.length;
act(() => {
const latest = mockInstances[mockInstances.length - 1]
latest._triggerError()
})
const latest = mockInstances[mockInstances.length - 1];
latest._triggerError();
});
// Should be reconnecting again (counter reset → still has attempts)
const newStates = stateChanges.slice(statesBefore)
expect(newStates).toContain('reconnecting')
})
const newStates = stateChanges.slice(statesBefore);
expect(newStates).toContain('reconnecting');
});
it('closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
const firstEs = mockInstances[0]
const firstEs = mockInstances[0];
// Trigger error to schedule a reconnect timer
act(() => {
firstEs._triggerError()
})
firstEs._triggerError();
});
// Unmount — should cancel the pending timer and close the EventSource
unmount()
unmount();
const instanceCountAfterUnmount = mockInstances.length
const instanceCountAfterUnmount = mockInstances.length;
// Advance well past any backoff timer — no new instances should be created
act(() => {
vi.advanceTimersByTime(30_000)
})
vi.advanceTimersByTime(30_000);
});
expect(mockInstances.length).toBe(instanceCountAfterUnmount) // no new connection post-unmount
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy()
})
expect(mockInstances.length).toBe(instanceCountAfterUnmount); // no new connection post-unmount
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy();
});
it('uses withCredentials: true on EventSource (Pitfall 7 — session cookie)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
const es = mockInstances[0]
expect(es.withCredentials).toBe(true)
unmount()
})
})
const es = mockInstances[0];
expect(es.withCredentials).toBe(true);
unmount();
});
});