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
|
|
|
});
|