Skip to content

Commit ade849c

Browse files
committed
test: add WebSocket server tests — connect, broadcast, events, cleanup (7 tests)
Cover WS connection, path rejection, event broadcast (note, task, skill, relation events), and listener cleanup verification. Coverage: 32% → 57% lines.
1 parent 7cb9f92 commit ade849c

1 file changed

Lines changed: 144 additions & 0 deletions

File tree

src/tests/websocket.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import http from 'http';
2+
import { WebSocket } from 'ws';
3+
import { EventEmitter } from 'events';
4+
import { attachWebSocket, type WebSocketHandle } from '@/api/rest/websocket';
5+
import type { ProjectManager } from '@/lib/project-manager';
6+
7+
function createMockProjectManager(): ProjectManager & EventEmitter {
8+
const em = new EventEmitter();
9+
// Minimal mock — only event emitter behavior needed
10+
(em as any).getProject = (id: string) => {
11+
if (id === 'test') return {
12+
config: {
13+
graphConfigs: {
14+
docs: { enabled: true }, code: { enabled: true },
15+
knowledge: { enabled: true }, files: { enabled: true },
16+
tasks: { enabled: true }, skills: { enabled: true },
17+
},
18+
},
19+
workspaceId: undefined,
20+
};
21+
return undefined;
22+
};
23+
(em as any).getWorkspace = () => undefined;
24+
return em as any;
25+
}
26+
27+
function waitForOpen(ws: WebSocket): Promise<void> {
28+
return new Promise((resolve, reject) => {
29+
ws.on('open', resolve);
30+
ws.on('error', reject);
31+
setTimeout(() => reject(new Error('WS open timeout')), 3000);
32+
});
33+
}
34+
35+
function waitForMessage(ws: WebSocket): Promise<any> {
36+
return new Promise((resolve, reject) => {
37+
ws.on('message', (data) => resolve(JSON.parse(data.toString())));
38+
setTimeout(() => reject(new Error('WS message timeout')), 3000);
39+
});
40+
}
41+
42+
describe('WebSocket server', () => {
43+
let server: http.Server;
44+
let wsHandle: WebSocketHandle;
45+
let pm: ProjectManager & EventEmitter;
46+
let port: number;
47+
48+
beforeAll(async () => {
49+
pm = createMockProjectManager();
50+
server = http.createServer();
51+
wsHandle = attachWebSocket(server, pm);
52+
53+
await new Promise<void>((resolve) => {
54+
server.listen(0, '127.0.0.1', () => {
55+
port = (server.address() as any).port;
56+
resolve();
57+
});
58+
});
59+
});
60+
61+
afterAll(async () => {
62+
// Close all connected clients and WSS
63+
for (const client of wsHandle.wss.clients) {
64+
client.terminate();
65+
}
66+
wsHandle.cleanup();
67+
await new Promise<void>((resolve) => wsHandle.wss.close(() => resolve()));
68+
server.closeAllConnections();
69+
await new Promise<void>((resolve) => server.close(() => resolve()));
70+
});
71+
72+
it('connects to /api/ws without auth', async () => {
73+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/ws`);
74+
await waitForOpen(ws);
75+
expect(ws.readyState).toBe(WebSocket.OPEN);
76+
ws.close();
77+
});
78+
79+
it('rejects non /api/ws paths', async () => {
80+
const ws = new WebSocket(`ws://127.0.0.1:${port}/other`);
81+
await expect(waitForOpen(ws)).rejects.toThrow();
82+
});
83+
84+
it('broadcasts note:created event', async () => {
85+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/ws`);
86+
await waitForOpen(ws);
87+
88+
const msgPromise = waitForMessage(ws);
89+
pm.emit('note:created', { projectId: 'test', noteId: 'n1' });
90+
const msg = await msgPromise;
91+
92+
expect(msg.type).toBe('note:created');
93+
expect(msg.projectId).toBe('test');
94+
expect(msg.data.noteId).toBe('n1');
95+
ws.close();
96+
});
97+
98+
it('broadcasts task:moved event', async () => {
99+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/ws`);
100+
await waitForOpen(ws);
101+
102+
const msgPromise = waitForMessage(ws);
103+
pm.emit('task:moved', { projectId: 'test', taskId: 't1', status: 'done' });
104+
const msg = await msgPromise;
105+
106+
expect(msg.type).toBe('task:moved');
107+
expect(msg.data.taskId).toBe('t1');
108+
ws.close();
109+
});
110+
111+
it('broadcasts relation events', async () => {
112+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/ws`);
113+
await waitForOpen(ws);
114+
115+
const msgPromise = waitForMessage(ws);
116+
pm.emit('note:relation:added', { projectId: 'test', noteId: 'n1', toId: 'n2', kind: 'refs' });
117+
const msg = await msgPromise;
118+
119+
expect(msg.type).toBe('note:relation:added');
120+
ws.close();
121+
});
122+
123+
it('broadcasts skill events', async () => {
124+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/ws`);
125+
await waitForOpen(ws);
126+
127+
const msgPromise = waitForMessage(ws);
128+
pm.emit('skill:created', { projectId: 'test', skillId: 's1' });
129+
const msg = await msgPromise;
130+
131+
expect(msg.type).toBe('skill:created');
132+
ws.close();
133+
});
134+
135+
it('cleanup removes listeners', () => {
136+
const pm2 = createMockProjectManager();
137+
const server2 = http.createServer();
138+
const handle = attachWebSocket(server2, pm2);
139+
expect(pm2.listenerCount('note:created')).toBeGreaterThan(0);
140+
handle.cleanup();
141+
expect(pm2.listenerCount('note:created')).toBe(0);
142+
server2.close();
143+
});
144+
});

0 commit comments

Comments
 (0)