Skip to content

Commit 6ae051a

Browse files
authored
Merge pull request #139 from Meet-hybrid/feat/socketio-client
feat: Socket.IO client integration for persistent WS state updates
2 parents 43c60cd + 429b6cf commit 6ae051a

14 files changed

Lines changed: 803 additions & 87 deletions

File tree

package-lock.json

Lines changed: 60 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"simulate": "node scripts/mock-webhook.js"
1414
},
1515
"dependencies": {
16-
"@next/swc-win32-x64-msvc": "^14.2.4",
16+
"@dnd-kit/core": "^6.3.1",
17+
"@dnd-kit/sortable": "^10.0.0",
18+
"@dnd-kit/utilities": "^3.2.2",
1719
"@stellar/freighter-api": "^6.0.1",
1820
"@stellar/stellar-sdk": "^11.3.0",
1921
"clsx": "^2.1.1",
@@ -32,6 +34,7 @@
3234
"react-i18next": "^15.5.3",
3335
"recharts": "2.12.7",
3436
"shepherd.js": "13.0.3",
37+
"socket.io-client": "^4.8.3",
3538
"tailwind-merge": "^2.3.0",
3639
"zod": "^4.4.3"
3740
},

src/app/layout.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ErrorProvider } from '@/components/ErrorModal';
1010
import { ThemeProvider } from '@/context/ThemeContext';
1111
import { I18nProvider } from '@/i18n';
1212
import { NetworkProvider } from '@/context/NetworkContext';
13+
import { SocketIOProvider } from '@/context/SocketIOContext';
1314

1415
const inter = Inter({ subsets: ['latin'] });
1516

@@ -30,15 +31,17 @@ export default function RootLayout({ children }: RootLayoutProps): ReactElement
3031
<AlertProvider>
3132
<ThemeProvider>
3233
<NetworkProvider>
33-
<WalletProvider>
34-
<RoleProvider>
35-
<ErrorProvider>
36-
<ToastProvider>
37-
{children}
38-
</ToastProvider>
39-
</ErrorProvider>
40-
</RoleProvider>
41-
</WalletProvider>
34+
<WalletProvider>
35+
<RoleProvider>
36+
<SocketIOProvider>
37+
<ErrorProvider>
38+
<ToastProvider>
39+
{children}
40+
</ToastProvider>
41+
</ErrorProvider>
42+
</SocketIOProvider>
43+
</RoleProvider>
44+
</WalletProvider>
4245
</NetworkProvider>
4346
</ThemeProvider>
4447
</AlertProvider>

src/components/VoteButton/__tests__/VoteButton.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ jest.mock('@/services/contractClient', () => ({
1111
jest.mock('@/context/RoleContext', () => ({
1212
useRole: jest.fn(),
1313
}));
14-
jest.mock('@/context/NetworkContext', () => ({
15-
useNetwork: jest.fn(),
16-
}));
1714
jest.mock('@/components/Toast');
1815
jest.mock('@/utils/logger', () => ({
1916
appendAuditEvent: jest.fn(() => Promise.resolve()),
@@ -39,6 +36,7 @@ import { useNetwork } from '@/context/NetworkContext';
3936
const mockCastVote = castVote as jest.MockedFunction<typeof castVote>;
4037
const mockUseRole = useRole as jest.MockedFunction<typeof useRole>;
4138
const mockUseToast = useToast as jest.MockedFunction<typeof useToast>;
39+
const mockUseNetwork = useNetwork as jest.MockedFunction<typeof useNetwork>;
4240
const mockShowToast = jest.fn();
4341
const mockRefreshRole = jest.fn();
4442

src/context/SocketIOContext.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
3+
import {
4+
createContext,
5+
useContext,
6+
type ReactNode,
7+
} from 'react';
8+
import { useSocketIO, type UseSocketIOResult } from '@/hooks/useSocketIO';
9+
10+
const SocketIOContext = createContext<UseSocketIOResult | null>(null);
11+
12+
export function SocketIOProvider({ children }: { children: ReactNode }) {
13+
const socket = useSocketIO({
14+
autoConnect: true,
15+
invalidateOnEvent: true,
16+
});
17+
18+
return (
19+
<SocketIOContext.Provider value={socket}>
20+
{children}
21+
</SocketIOContext.Provider>
22+
);
23+
}
24+
25+
export function useSocketIOContext(): UseSocketIOResult {
26+
const ctx = useContext(SocketIOContext);
27+
if (!ctx) {
28+
throw new Error('useSocketIOContext must be used within a SocketIOProvider');
29+
}
30+
return ctx;
31+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import type { Socket } from 'socket.io-client';
3+
import { useSocketIO } from '@/hooks/useSocketIO';
4+
import {
5+
getSocket,
6+
resetSocketClientForTests,
7+
} from '@/services/socketClient';
8+
9+
type MockedSocket = jest.Mocked<Pick<Socket, 'on' | 'off' | 'emit' | 'disconnect' | 'connect' | 'removeAllListeners'>> & {
10+
onAny: jest.Mock;
11+
connected: boolean;
12+
auth: Record<string, unknown>;
13+
};
14+
15+
function getMockedSocket(): MockedSocket {
16+
return getSocket() as unknown as MockedSocket;
17+
}
18+
19+
jest.mock('socket.io-client', () => {
20+
const mockSocket: MockedSocket = {
21+
on: jest.fn(),
22+
off: jest.fn(),
23+
emit: jest.fn(),
24+
disconnect: jest.fn(),
25+
connect: jest.fn(),
26+
removeAllListeners: jest.fn(),
27+
connected: false,
28+
auth: {},
29+
onAny: jest.fn(),
30+
};
31+
return {
32+
io: jest.fn(() => mockSocket),
33+
};
34+
});
35+
36+
const mockUseEvents = jest.fn().mockReturnValue({
37+
emit: jest.fn(),
38+
timeline: [],
39+
clear: jest.fn(),
40+
});
41+
42+
jest.mock('@/hooks/useEvents', () => ({
43+
useEvents: (opts?: { maxEvents?: number }) => mockUseEvents(opts),
44+
}));
45+
46+
const mockInvalidateChainState = jest.fn();
47+
jest.mock('@/hooks/useChainState', () => ({
48+
invalidateChainState: (...args: unknown[]) => mockInvalidateChainState(...args),
49+
}));
50+
51+
const OLD_ENV = process.env;
52+
53+
beforeEach(() => {
54+
jest.resetModules();
55+
process.env = { ...OLD_ENV };
56+
process.env.NEXT_PUBLIC_SOCKET_IO_URL = 'http://localhost:3001';
57+
});
58+
59+
afterEach(() => {
60+
process.env = OLD_ENV;
61+
resetSocketClientForTests();
62+
jest.clearAllMocks();
63+
});
64+
65+
describe('useSocketIO', () => {
66+
it('returns initial disconnected status', () => {
67+
const { result } = renderHook(() => useSocketIO({ autoConnect: false }));
68+
expect(result.current.status).toBe('disconnected');
69+
expect(result.current.isConnected).toBe(false);
70+
expect(result.current.lastEvent).toBeNull();
71+
});
72+
73+
it('connects on autoConnect by default', () => {
74+
renderHook(() => useSocketIO());
75+
expect(getSocket()).not.toBeNull();
76+
});
77+
78+
it('respects autoConnect=false', () => {
79+
renderHook(() => useSocketIO({ autoConnect: false }));
80+
expect(getSocket()).toBeNull();
81+
});
82+
83+
it('connect function triggers connection', () => {
84+
const { result } = renderHook(() => useSocketIO({ autoConnect: false }));
85+
act(() => { result.current.connect(); });
86+
expect(getSocket()).not.toBeNull();
87+
});
88+
89+
it('disconnect function disconnects', () => {
90+
const { result } = renderHook(() => useSocketIO({ autoConnect: true }));
91+
act(() => { result.current.disconnect(); });
92+
expect(getSocket()).toBeNull();
93+
});
94+
95+
it('receives lastEvent when Socket.IO event fires', () => {
96+
const { result } = renderHook(() =>
97+
useSocketIO({ autoConnect: true, invalidateOnEvent: false }),
98+
);
99+
100+
const socket = getMockedSocket();
101+
const onAnyCalls = (socket.onAny as jest.Mock).mock.calls;
102+
const onAnyHandler = onAnyCalls[0]?.[0];
103+
104+
act(() => {
105+
if (onAnyHandler) onAnyHandler('vote:cast', { prId: 42 });
106+
});
107+
108+
expect(result.current.lastEvent).toEqual({
109+
event: 'vote:cast',
110+
data: { prId: 42 },
111+
});
112+
});
113+
114+
it('calls invalidateChainState on events', () => {
115+
renderHook(() => useSocketIO({ autoConnect: true, invalidateOnEvent: true }));
116+
117+
const socket = getMockedSocket();
118+
const onAnyCalls = (socket.onAny as jest.Mock).mock.calls;
119+
const onAnyHandler = onAnyCalls[0]?.[0];
120+
121+
act(() => {
122+
if (onAnyHandler) onAnyHandler('pr:update', { id: 42 });
123+
});
124+
125+
expect(mockInvalidateChainState).toHaveBeenCalledWith(
126+
expect.arrayContaining(['prs', 'dashboard']),
127+
'websocket',
128+
);
129+
});
130+
131+
it('invokes useEvents emit when events arrive', () => {
132+
const mockEmit = jest.fn();
133+
mockUseEvents.mockReturnValue({ emit: mockEmit, timeline: [], clear: jest.fn() });
134+
135+
renderHook(() => useSocketIO({ autoConnect: true }));
136+
137+
const socket = getMockedSocket();
138+
const onAnyCalls = (socket.onAny as jest.Mock).mock.calls;
139+
const onAnyHandler = onAnyCalls[0]?.[0];
140+
141+
act(() => {
142+
if (onAnyHandler) onAnyHandler('reputation:change', { score: 100 });
143+
});
144+
145+
expect(mockEmit).toHaveBeenCalledWith(
146+
expect.objectContaining({ type: 'reputation_change', resource: 'socket.io' }),
147+
);
148+
});
149+
150+
it('updateToken calls socket client updateAuthToken', () => {
151+
const { result } = renderHook(() => useSocketIO({ autoConnect: true }));
152+
153+
const socket = getMockedSocket();
154+
155+
act(() => { result.current.updateToken('new-token'); });
156+
157+
expect(socket.disconnect as jest.Mock).toHaveBeenCalled();
158+
expect(socket.connect as jest.Mock).toHaveBeenCalled();
159+
});
160+
});

0 commit comments

Comments
 (0)