[#451] added websocket connexion (#453)

* [#451] added websocket connexion

* [#451] added tests

* [#451] add missing cleanup for web socket causing memory leaks
This commit is contained in:
Camille Moussu
2026-01-14 15:17:29 +01:00
committed by GitHub
parent 8b3afc1737
commit 2fa8a7f16a
18 changed files with 1204 additions and 4 deletions
@@ -0,0 +1,518 @@
import { cleanup, render, waitFor, act } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection";
import { registerToCalendars } from "../../../src/websocket/ws/registerToCalendars";
import { unregisterToCalendars } from "../../../src/websocket/ws/unregisterToCalendars";
import { WebSocketGate } from "../../../src/websocket/WebSocketGate";
import { setSelectedCalendars } from "../../../src/utils/storage/setSelectedCalendars";
jest.mock("../../../src/websocket/createWebSocketConnection");
jest.mock("../../../src/websocket/ws/registerToCalendars");
jest.mock("../../../src/websocket/ws/unregisterToCalendars");
describe("WebSocketGate", () => {
let store: any;
let mockSocket: any;
const createMockStore = (
userData: any = { id: "1" },
tokens: any = { access: "token" }
) => {
return configureStore({
reducer: {
user: (state = { userData, tokens }) => state,
calendars: (state = { list: {} }) => state,
},
});
};
const createMockSocket = (readyState = WebSocket.OPEN) => ({
readyState,
close: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
cleanup: jest.fn(),
});
beforeEach(() => {
store = createMockStore();
mockSocket = createMockSocket();
localStorage.clear();
});
afterEach(() => {
cleanup();
jest.clearAllMocks();
localStorage.clear();
});
describe("Authentication", () => {
it("should not create connection when user is not authenticated", () => {
const unauthStore = createMockStore(null, null);
render(
<Provider store={unauthStore}>
<WebSocketGate />
</Provider>
);
expect(createWebSocketConnection).not.toHaveBeenCalled();
});
it("should create connection when user is authenticated", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
});
});
it("should close existing socket when user logs out", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
const { rerender } = render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
const unauthStore = createMockStore(null, null);
rerender(
<Provider store={unauthStore}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(mockSocket.close).toHaveBeenCalled();
});
});
});
describe("Socket Connection Management", () => {
it("should add close event listener on socket connection", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(mockSocket.addEventListener).toHaveBeenCalledWith(
"close",
expect.any(Function)
);
});
});
it("should handle socket close event", async () => {
let closeHandler: Function;
mockSocket.addEventListener = jest.fn((event, handler) => {
if (event === "close") {
closeHandler = handler;
}
});
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(mockSocket.addEventListener).toHaveBeenCalled();
});
await act(async () => {
closeHandler!();
});
// Verify that subsequent calendar changes don't try to register
await act(async () => {
setSelectedCalendars(["cal1"]);
});
await waitFor(() => {
expect(registerToCalendars).not.toHaveBeenCalled();
});
});
it("should handle connection errors gracefully", async () => {
const consoleError = jest.spyOn(console, "error").mockImplementation();
(createWebSocketConnection as jest.Mock).mockRejectedValue(
new Error("Connection failed")
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to create WebSocket connection:",
expect.any(Error)
);
});
consoleError.mockRestore();
});
it("should close socket on component unmount", async () => {
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
const { unmount } = render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
unmount();
expect(mockSocket.close).toHaveBeenCalled();
});
});
describe("Calendar Registration", () => {
it("should not register if socket is not open", async () => {
localStorage.setItem(
"selectedCalendars",
JSON.stringify(["cal1", "cal2"])
);
const connectingSocket = createMockSocket(WebSocket.CONNECTING);
(createWebSocketConnection as jest.Mock).mockResolvedValue(
connectingSocket
);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
expect(registerToCalendars).not.toHaveBeenCalled();
});
it("should register to calendars when socket opens and calendars are selected", async () => {
localStorage.setItem(
"selectedCalendars",
JSON.stringify(["cal1", "cal2"])
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal1",
"/calendars/cal2",
]);
});
});
it("should register only new calendars when calendar list changes", async () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal1",
]);
});
jest.clearAllMocks();
await act(async () => {
setSelectedCalendars(["cal1", "cal2", "cal3"]);
});
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal2",
"/calendars/cal3",
]);
});
});
it("should unregister removed calendars when calendar list changes", async () => {
localStorage.setItem(
"selectedCalendars",
JSON.stringify(["cal1", "cal2", "cal3"])
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
});
jest.clearAllMocks();
await act(async () => {
setSelectedCalendars(["cal1"]);
});
await waitFor(() => {
expect(unregisterToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal2",
"/calendars/cal3",
]);
});
});
it("should both register and unregister when calendar list partially changes", async () => {
localStorage.setItem(
"selectedCalendars",
JSON.stringify(["cal1", "cal2"])
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
});
jest.clearAllMocks();
await act(async () => {
setSelectedCalendars(["cal1", "cal3"]);
});
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal3",
]);
expect(unregisterToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal2",
]);
});
});
it("should handle registration errors and not update previous calendar list", async () => {
const consoleError = jest.spyOn(console, "error").mockImplementation();
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
(registerToCalendars as jest.Mock).mockImplementation(() => {
throw new Error("Registration failed");
});
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to update calendar registrations:",
expect.any(Error)
);
});
await waitFor(() => {
expect(mockSocket.close).not.toHaveBeenCalled();
});
jest.clearAllMocks();
(registerToCalendars as jest.Mock).mockImplementation(() => {});
await act(async () => {
setSelectedCalendars(["cal1", "cal2"]);
});
await waitFor(() => {
// Should register both calendars since previous update failed and didn't update the ref
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
"/calendars/cal1",
"/calendars/cal2",
]);
});
consoleError.mockRestore();
});
it("should not attempt registration when no calendars are selected", async () => {
localStorage.setItem("selectedCalendars", JSON.stringify([]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
expect(registerToCalendars).not.toHaveBeenCalled();
expect(unregisterToCalendars).not.toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle rapid calendar changes correctly", async () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalledTimes(1);
});
// Rapid changes
await act(async () => {
setSelectedCalendars(["cal1", "cal2"]);
setSelectedCalendars(["cal1", "cal2", "cal3"]);
});
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
});
});
it("should handle socket closed during calendar update", async () => {
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
});
jest.clearAllMocks();
await act(async () => {
mockSocket.readyState = WebSocket.CLOSED;
setSelectedCalendars(["cal1", "cal2"]);
});
// Wait a bit to ensure the effect would have run if it was going to
await new Promise((resolve) => setTimeout(resolve, 100));
expect(registerToCalendars).not.toHaveBeenCalled();
});
it("should handle unregistration errors gracefully", async () => {
const consoleError = jest.spyOn(console, "error").mockImplementation();
localStorage.setItem(
"selectedCalendars",
JSON.stringify(["cal1", "cal2"])
);
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(registerToCalendars).toHaveBeenCalled();
});
jest.clearAllMocks();
(unregisterToCalendars as jest.Mock).mockImplementation(() => {
throw new Error("Unregistration failed");
});
await act(async () => {
setSelectedCalendars(["cal1"]);
});
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to update calendar registrations:",
expect.any(Error)
);
});
consoleError.mockRestore();
});
it("should handle socket that becomes open after initial connection", async () => {
const closedSocket = createMockSocket(WebSocket.CONNECTING);
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket);
render(
<Provider store={store}>
<WebSocketGate />
</Provider>
);
await waitFor(() => {
expect(createWebSocketConnection).toHaveBeenCalled();
});
// Socket is not open yet, should not register
expect(registerToCalendars).not.toHaveBeenCalled();
// Simulate socket becoming open
await act(async () => {
closedSocket.readyState = WebSocket.OPEN;
// Trigger a calendar change to re-run the effect
setSelectedCalendars(["cal1", "cal2"]);
});
// Still should not register because isSocketOpen state is false
// The component only sets isSocketOpen to true when socket.readyState is OPEN during connection
expect(registerToCalendars).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,46 @@
import { api } from "../../../../src/utils/apiUtils";
import { fetchWebSocketTicket } from "../../../../src/websocket/api/fetchWebSocketTicket";
jest.mock("../../../../src/utils/apiUtils");
describe("fetchWebSocketTicket", () => {
const mockTicket = {
clientAddress: "127.0.0.1",
value: "test-ticket-123",
generatedOn: "2025-01-12T10:00:00Z",
validUntil: "2025-01-12T11:00:00Z",
username: "testuser",
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should fetch ticket successfully", async () => {
(api.post as jest.Mock).mockResolvedValue({
ok: true,
json: async () => mockTicket,
});
const ticket = await fetchWebSocketTicket();
expect(api.post).toHaveBeenCalledWith("ws/ticket");
expect(ticket).toEqual(mockTicket);
});
it("should throw error when response is not ok", async () => {
(api.post as jest.Mock).mockResolvedValue({
ok: false,
});
await expect(fetchWebSocketTicket()).rejects.toThrow(
"Failed to fetch WebSocket ticket"
);
});
it("should throw error when network fails", async () => {
(api.post as jest.Mock).mockRejectedValue(new Error("Network error"));
await expect(fetchWebSocketTicket()).rejects.toThrow("Network error");
});
});
@@ -0,0 +1,152 @@
import { waitFor } from "@testing-library/dom";
import { fetchWebSocketTicket } from "../../../src/websocket/api/fetchWebSocketTicket";
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection";
import { WS_INBOUND_EVENTS } from "../../../src/websocket/protocols";
import { setupWebsocket } from "./utils/setupWebsocket";
jest.mock("../../../src/websocket/api/fetchWebSocketTicket");
describe("createWebSocketConnection", () => {
let mockWebSocket: jest.Mock;
let cleanup: () => void;
let webSocketInstances: any[] = [];
const mockTicket = {
value: "test-ticket-123",
clientAddress: "127.0.0.1",
generatedOn: "2025-01-12T10:00:00Z",
validUntil: "2025-01-12T11:00:00Z",
username: "testuser",
};
/** ---------- Helpers ---------- */
const getWs = () => webSocketInstances[0];
const triggerEvent = (ws: any, event: string, payload?: any) => {
ws._listeners[event]?.[0]?.(payload);
};
const createAndOpenConnection = async () => {
const promise = createWebSocketConnection();
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
});
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED);
const socket = await promise;
return { socket, ws: getWs(), promise };
};
/** ---------- Setup ---------- */
beforeEach(() => {
({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket());
(window as any).WEBSOCKET_URL = "wss://calendar.example.com";
(fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket);
});
afterEach(() => {
jest.clearAllMocks();
delete (window as any).WEBSOCKET_URL;
delete (window as any).CALENDAR_BASE_URL;
cleanup();
});
/** ---------- Tests ---------- */
it("throws when WEBSOCKET_URL is not defined", async () => {
delete (window as any).WEBSOCKET_URL;
await expect(createWebSocketConnection()).rejects.toThrow(
"WEBSOCKET_URL is not defined"
);
});
it("fetches WebSocket ticket", async () => {
await createAndOpenConnection();
expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1);
});
it("creates WebSocket with correct URL and ticket", async () => {
await createAndOpenConnection();
expect(mockWebSocket).toHaveBeenCalledWith(
"wss://calendar.example.com/ws?ticket=test-ticket-123"
);
});
it("creates WebSocket with correct URL and ticket without the WEBSOCKET_URL", async () => {
delete (window as any).WEBSOCKET_URL;
(window as any).CALENDAR_BASE_URL = "https://calendar.example.com";
await createAndOpenConnection();
expect(mockWebSocket).toHaveBeenCalledWith(
"wss://calendar.example.com/ws?ticket=test-ticket-123"
);
});
it("resolves with socket when connection opens", async () => {
const { socket, ws } = await createAndOpenConnection();
expect(socket).toBe(ws);
});
it("rejects when connection fails", async () => {
const promise = createWebSocketConnection();
await waitFor(() => {
expect(webSocketInstances.length).toBe(1);
});
triggerEvent(getWs(), WS_INBOUND_EVENTS.ERROR, new Event("error"));
await expect(promise).rejects.toThrow("WebSocket connection failed");
});
it("attaches message event listener", async () => {
const { ws } = await createAndOpenConnection();
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE]).toBeDefined();
expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE].length).toBeGreaterThan(0);
});
it("attaches close event listener", async () => {
const { ws } = await createAndOpenConnection();
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined();
});
it("parses and logs incoming messages", async () => {
const logSpy = jest.spyOn(console, "log").mockImplementation();
const { ws } = await createAndOpenConnection();
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, {
data: JSON.stringify({ type: "test", payload: "data" }),
});
expect(logSpy).toHaveBeenCalledWith("WebSocket message received:", {
type: "test",
payload: "data",
});
logSpy.mockRestore();
});
it("handles invalid JSON messages", async () => {
const errorSpy = jest.spyOn(console, "error").mockImplementation();
const { ws } = await createAndOpenConnection();
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" });
expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse WebSocket message:",
expect.any(Error)
);
errorSpy.mockRestore();
});
});
@@ -0,0 +1,35 @@
export function setupWebsocket() {
const originalWebSocket = global.WebSocket;
const webSocketInstances: any[] = [];
const mockWebSocket = jest.fn().mockImplementation((url: string) => {
const ws = {
url,
readyState: WebSocket.CONNECTING,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
send: jest.fn(),
close: jest.fn(),
_listeners: {} as Record<string, Function[]>,
};
ws.addEventListener.mockImplementation((event, handler) => {
ws._listeners[event] ??= [];
ws._listeners[event].push(handler);
});
ws.removeEventListener.mockImplementation((event, handler) => {
ws._listeners[event] =
ws._listeners[event]?.filter((h) => h !== handler) ?? [];
});
webSocketInstances.push(ws);
return ws;
});
global.WebSocket = mockWebSocket as any;
const cleanup = () => {
global.WebSocket = originalWebSocket;
webSocketInstances.length = 0;
};
return { webSocketInstances, mockWebSocket, cleanup };
}
@@ -0,0 +1,57 @@
import { registerToCalendars } from "../../../../src/websocket/ws/registerToCalendars";
describe("registerToCalendars", () => {
let mockSocket: any;
beforeEach(() => {
mockSocket = {
readyState: WebSocket.OPEN,
send: jest.fn(),
};
});
it("should send registration message with calendar URIs", () => {
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
registerToCalendars(mockSocket, calendarURIs);
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
register: calendarURIs,
})
);
});
it("should throw error if socket is not open", () => {
mockSocket.readyState = WebSocket.CONNECTING;
const calendarURIs = ["/calendars/cal1"];
expect(() => registerToCalendars(mockSocket, calendarURIs)).toThrow(
"Cannot register: WebSocket is not open"
);
});
it("should handle empty calendar list", () => {
registerToCalendars(mockSocket, []);
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
register: [],
})
);
});
it("should log registration", () => {
const consoleLogSpy = jest.spyOn(console, "log").mockImplementation();
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
registerToCalendars(mockSocket, calendarURIs);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Registered to calendars",
calendarURIs
);
consoleLogSpy.mockRestore();
});
});
@@ -0,0 +1,60 @@
// src/websocket/__tests__/unregisterToCalendars.test.ts
import { unregisterToCalendars } from "../../../../src/websocket/ws/unregisterToCalendars";
describe("unregisterToCalendars", () => {
let mockSocket: any;
beforeEach(() => {
mockSocket = {
readyState: WebSocket.OPEN,
send: jest.fn(),
};
jest.clearAllMocks();
});
it("should send unregistration message with calendar URIs", () => {
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
unregisterToCalendars(mockSocket, calendarURIs);
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
unregister: calendarURIs,
})
);
});
it("should throw error if socket is not open", () => {
mockSocket.readyState = WebSocket.CONNECTING;
const calendarURIs = ["/calendars/cal1"];
expect(() => unregisterToCalendars(mockSocket, calendarURIs)).toThrow(
"Cannot unregister: WebSocket is not open"
);
});
it("should handle empty calendar list", () => {
unregisterToCalendars(mockSocket, []);
expect(mockSocket.send).toHaveBeenCalledWith(
JSON.stringify({
unregister: [],
})
);
});
it("should log unregistration", () => {
const consoleLogSpy = jest.spyOn(console, "log").mockImplementation();
const calendarURIs = ["/calendars/cal1", "/calendars/cal2"];
unregisterToCalendars(mockSocket, calendarURIs);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Unregistered to calendars",
calendarURIs
);
consoleLogSpy.mockRestore();
});
});