MeshiTrack/packages/web/tests/lib/useShoppingListSync.test.ts

196 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-05-19 10:13:40 +09:00
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
2026-05-19 11:06:03 +09:00
import { useShoppingListSync } from '../../src/lib/useShoppingListSync';
2026-05-19 10:13:40 +09:00
vi.mock('@/services/shopping-lists', () => ({
getShoppingListSyncSocketUrl: vi.fn(() => 'ws://localhost/sync'),
}));
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
url: string;
readyState = 1; // OPEN
onopen: any = null;
onmessage: any = null;
onerror: any = null;
onclose: any = null;
send = vi.fn();
close = vi.fn();
constructor(url: string) {
this.url = url;
}
}
describe('useShoppingListSync', () => {
let originalWebSocket: any;
let createdSockets: MockWebSocket[] = [];
beforeEach(() => {
vi.clearAllMocks();
createdSockets = [];
originalWebSocket = global.WebSocket;
const MockClass = class extends MockWebSocket {
constructor(url: string) {
super(url);
createdSockets.push(this);
}
};
(MockClass as any).OPEN = 1;
(MockClass as any).CONNECTING = 0;
global.WebSocket = MockClass as any;
});
afterEach(() => {
global.WebSocket = originalWebSocket;
});
it('initializes and connects to the correct socket URL', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
expect(createdSockets).toHaveLength(1);
expect(createdSockets[0].url).toBe('ws://localhost/sync');
});
it('handles incoming item_updated messages correctly', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => {
if (ws.onmessage) ws.onmessage({ data: JSON.stringify({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }) });
});
expect(onSync).toHaveBeenCalledWith({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } });
});
it('broadcasts toggle item messages when connected', () => {
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => {
result.current.toggleItemCheck('item1', true);
});
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({
type: 'TOGGLE_ITEM',
itemId: 'item1',
checked: true,
}));
});
it('handles disconnect and reconnect backoff', () => {
vi.useFakeTimers();
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
let ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
expect(result.current.isConnected).toBe(true);
act(() => { if (ws.onclose) ws.onclose({ reason: 'test' }); });
expect(result.current.isConnected).toBe(false);
// After 1000ms it should attempt reconnect
act(() => { vi.advanceTimersByTime(1000); });
expect(createdSockets).toHaveLength(2);
vi.useRealTimers();
});
2026-05-19 14:09:03 +09:00
it('handles invalid onmessage payload gracefully', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
// Should swallow invalid JSON parse error
act(() => {
if (ws.onmessage) ws.onmessage({ data: 'invalid-json' });
});
expect(onSync).not.toHaveBeenCalled();
});
it('handles ws error states', () => {
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onerror) ws.onerror(); });
expect(result.current.error).toBe('Connection interrupt');
});
it('handles websocket initialization errors gracefully', () => {
global.WebSocket = function() {
throw new Error('WS Blocked');
} as any;
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
expect(result.current.error).toBe('Sync failed to initialize');
});
it('does not connect if householdId or listId is missing', () => {
const onSync = vi.fn();
renderHook(() => useShoppingListSync('', 'list1', onSync));
expect(createdSockets).toHaveLength(0);
renderHook(() => useShoppingListSync('hh1', '', onSync));
expect(createdSockets).toHaveLength(0);
});
it('handles disconnect and uses default severed message when reason is missing', () => {
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
act(() => { if (ws.onopen) ws.onopen(); });
act(() => { if (ws.onclose) ws.onclose({} as any); });
expect(result.current.isConnected).toBe(false);
});
it('stops attempting to reconnect after 5 consecutive failures', () => {
vi.useFakeTimers();
const onSync = vi.fn();
renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
expect(createdSockets).toHaveLength(1);
for (let attempt = 1; attempt <= 5; attempt++) {
const currentSocket = createdSockets[createdSockets.length - 1];
act(() => { if (currentSocket.onclose) currentSocket.onclose({ reason: 'fail' }); });
act(() => { vi.advanceTimersByTime(attempt * 1000); });
}
const totalSocketsCreated = createdSockets.length;
expect(totalSocketsCreated).toBe(6);
const finalSocket = createdSockets[totalSocketsCreated - 1];
act(() => { if (finalSocket.onclose) finalSocket.onclose({ reason: 'fail' }); });
act(() => { vi.advanceTimersByTime(6000); });
expect(createdSockets.length).toBe(totalSocketsCreated);
vi.useRealTimers();
});
it('does not send toggle item message if socket is not open', () => {
const onSync = vi.fn();
const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync));
const ws = createdSockets[0];
ws.readyState = 0; // CONNECTING
act(() => {
result.current.toggleItemCheck('item1', true);
});
expect(ws.send).not.toHaveBeenCalled();
});
2026-05-19 10:13:40 +09:00
});