From 2fa8a7f16af78b734b0b9faf3c740a6ba5c275c5 Mon Sep 17 00:00:00 2001
From: Camille Moussu <66134347+Eriikah@users.noreply.github.com>
Date: Wed, 14 Jan 2026 15:17:29 +0100
Subject: [PATCH] [#451] added websocket connexion (#453)
* [#451] added websocket connexion
* [#451] added tests
* [#451] add missing cleanup for web socket causing memory leaks
---
.../features/websocket/WebSocketGate.test.tsx | 518 ++++++++++++++++++
.../api/fetchWebSocketTicket.test.tsx | 46 ++
.../createWebSocketConnection.test.tsx | 152 +++++
.../websocket/utils/setupWebsocket.ts | 35 ++
.../websocketAPI/registerToCalendars.test.tsx | 57 ++
.../unregisterToCalendars.test.tsx | 60 ++
public/.env.example.js | 1 +
src/App.tsx | 2 +
src/components/Calendar/Calendar.tsx | 6 +-
src/utils/storage/setSelectedCalendars.ts | 13 +
src/utils/storage/useSelectedCalendars.ts | 43 ++
src/websocket/WebSocketGate.tsx | 104 ++++
src/websocket/api/fetchWebSocketTicket.ts | 12 +
src/websocket/api/types.ts | 7 +
src/websocket/createWebSocketConnection.ts | 107 ++++
src/websocket/protocols.ts | 13 +
src/websocket/ws/registerToCalendars.ts | 16 +
src/websocket/ws/unregisterToCalendars.ts | 16 +
18 files changed, 1204 insertions(+), 4 deletions(-)
create mode 100644 __test__/features/websocket/WebSocketGate.test.tsx
create mode 100644 __test__/features/websocket/api/fetchWebSocketTicket.test.tsx
create mode 100644 __test__/features/websocket/createWebSocketConnection.test.tsx
create mode 100644 __test__/features/websocket/utils/setupWebsocket.ts
create mode 100644 __test__/features/websocket/websocketAPI/registerToCalendars.test.tsx
create mode 100644 __test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx
create mode 100644 src/utils/storage/setSelectedCalendars.ts
create mode 100644 src/utils/storage/useSelectedCalendars.ts
create mode 100644 src/websocket/WebSocketGate.tsx
create mode 100644 src/websocket/api/fetchWebSocketTicket.ts
create mode 100644 src/websocket/api/types.ts
create mode 100644 src/websocket/createWebSocketConnection.ts
create mode 100644 src/websocket/protocols.ts
create mode 100644 src/websocket/ws/registerToCalendars.ts
create mode 100644 src/websocket/ws/unregisterToCalendars.ts
diff --git a/__test__/features/websocket/WebSocketGate.test.tsx b/__test__/features/websocket/WebSocketGate.test.tsx
new file mode 100644
index 0000000..6a5891b
--- /dev/null
+++ b/__test__/features/websocket/WebSocketGate.test.tsx
@@ -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(
+
+
+
+ );
+
+ expect(createWebSocketConnection).not.toHaveBeenCalled();
+ });
+
+ it("should create connection when user is authenticated", async () => {
+ (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
+
+ render(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(createWebSocketConnection).toHaveBeenCalled();
+ });
+
+ const unauthStore = createMockStore(null, null);
+ rerender(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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();
+ });
+ });
+});
diff --git a/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx b/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx
new file mode 100644
index 0000000..7b739d9
--- /dev/null
+++ b/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx
@@ -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");
+ });
+});
diff --git a/__test__/features/websocket/createWebSocketConnection.test.tsx b/__test__/features/websocket/createWebSocketConnection.test.tsx
new file mode 100644
index 0000000..359a693
--- /dev/null
+++ b/__test__/features/websocket/createWebSocketConnection.test.tsx
@@ -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();
+ });
+});
diff --git a/__test__/features/websocket/utils/setupWebsocket.ts b/__test__/features/websocket/utils/setupWebsocket.ts
new file mode 100644
index 0000000..efde303
--- /dev/null
+++ b/__test__/features/websocket/utils/setupWebsocket.ts
@@ -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,
+ };
+
+ 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 };
+}
diff --git a/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx b/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx
new file mode 100644
index 0000000..8202192
--- /dev/null
+++ b/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx
@@ -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();
+ });
+});
diff --git a/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx b/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx
new file mode 100644
index 0000000..1525f42
--- /dev/null
+++ b/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx
@@ -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();
+ });
+});
diff --git a/public/.env.example.js b/public/.env.example.js
index 0ce6d30..405104b 100644
--- a/public/.env.example.js
+++ b/public/.env.example.js
@@ -10,3 +10,4 @@ var MAIL_SPA_URL = "https://mail.example.com";
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com";
var DEBUG = false;
var LANG = "en";
+var WEBSOCKET_URL = "wss://calendar.example.com";
diff --git a/src/App.tsx b/src/App.tsx
index 4ba7509..ad8be03 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,6 +26,7 @@ import fr from "./locales/fr.json";
import ru from "./locales/ru.json";
import vi from "./locales/vi.json";
import I18n from "twake-i18n";
+import { WebSocketGate } from "./websocket/WebSocketGate";
const locale = { en, fr, ru, vi };
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
@@ -68,6 +69,7 @@ function App() {
locales={dateLocales}
>
}>
+
} />
diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx
index 408f9d8..314181c 100644
--- a/src/components/Calendar/Calendar.tsx
+++ b/src/components/Calendar/Calendar.tsx
@@ -45,6 +45,7 @@ import SearchResultsPage from "../../features/Search/SearchResultsPage";
import { setTimeZone } from "../../features/Settings/SettingsSlice";
import { browserDefaultTimeZone } from "../../utils/timezone";
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
+import { setSelectedCalendars as setSelectedCalendarsToStorage } from "../../utils/storage/setSelectedCalendars";
const localeMap: Record = {
fr: frLocale,
@@ -164,10 +165,7 @@ export default function CalendarApp({
// Save selected cals to cache
useEffect(() => {
if (calendarIds.length > 0) {
- localStorage.setItem(
- "selectedCalendars",
- JSON.stringify(selectedCalendars)
- );
+ setSelectedCalendarsToStorage(selectedCalendars);
}
}, [selectedCalendars, calendarIds.length]);
diff --git a/src/utils/storage/setSelectedCalendars.ts b/src/utils/storage/setSelectedCalendars.ts
new file mode 100644
index 0000000..6d93d65
--- /dev/null
+++ b/src/utils/storage/setSelectedCalendars.ts
@@ -0,0 +1,13 @@
+export function setSelectedCalendars(calendars: string[]) {
+ try {
+ localStorage.setItem("selectedCalendars", JSON.stringify(calendars));
+
+ window.dispatchEvent(
+ new CustomEvent("selectedCalendarsChanged", {
+ detail: calendars,
+ })
+ );
+ } catch (error) {
+ console.error("Failed to save selected calendars:", error);
+ }
+}
diff --git a/src/utils/storage/useSelectedCalendars.ts b/src/utils/storage/useSelectedCalendars.ts
new file mode 100644
index 0000000..7689f0d
--- /dev/null
+++ b/src/utils/storage/useSelectedCalendars.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from "react";
+
+export function useSelectedCalendars(): string[] {
+ const [calendars, setCalendars] = useState(() => {
+ try {
+ return JSON.parse(localStorage.getItem("selectedCalendars") ?? "[]");
+ } catch {
+ return [];
+ }
+ });
+
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === "selectedCalendars") {
+ try {
+ setCalendars(JSON.parse(e.newValue ?? "[]"));
+ } catch {
+ setCalendars([]);
+ }
+ }
+ };
+
+ const onLocalChange = (e: CustomEvent) => {
+ setCalendars(e.detail);
+ };
+
+ window.addEventListener("storage", onStorage);
+ window.addEventListener(
+ "selectedCalendarsChanged",
+ onLocalChange as EventListener
+ );
+
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(
+ "selectedCalendarsChanged",
+ onLocalChange as EventListener
+ );
+ };
+ }, []);
+
+ return calendars;
+}
diff --git a/src/websocket/WebSocketGate.tsx b/src/websocket/WebSocketGate.tsx
new file mode 100644
index 0000000..91036ba
--- /dev/null
+++ b/src/websocket/WebSocketGate.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useRef, useState } from "react";
+import { useAppSelector } from "../app/hooks";
+import { useSelectedCalendars } from "../utils/storage/useSelectedCalendars";
+import {
+ createWebSocketConnection,
+ WebSocketWithCleanup,
+} from "./createWebSocketConnection";
+import { registerToCalendars } from "./ws/registerToCalendars";
+import { unregisterToCalendars } from "./ws/unregisterToCalendars";
+
+export function WebSocketGate() {
+ const socketRef = useRef(null);
+ const previousCalendarListRef = useRef([]);
+
+ const isAuthenticated = useAppSelector((state) =>
+ Boolean(state.user.userData && state.user.tokens)
+ );
+ const [isSocketOpen, setIsSocketOpen] = useState(false);
+
+ const calendarList = useSelectedCalendars();
+
+ // Manage WebSocket connection
+ useEffect(() => {
+ if (!isAuthenticated) {
+ if (socketRef.current) {
+ socketRef.current.cleanup();
+ socketRef.current.close();
+ socketRef.current = null;
+ setIsSocketOpen(false);
+ }
+ return;
+ }
+
+ const connect = async () => {
+ try {
+ const socket = await createWebSocketConnection();
+ socketRef.current = socket;
+ socket.addEventListener("close", () => {
+ setIsSocketOpen(false);
+ socketRef.current = null;
+ });
+ // Check if socket closed during setup
+ if (socket.readyState === WebSocket.OPEN) {
+ setIsSocketOpen(true);
+ }
+ } catch (error) {
+ console.error("Failed to create WebSocket connection:", error);
+ }
+ };
+
+ connect();
+
+ return () => {
+ if (socketRef.current) {
+ socketRef.current.cleanup();
+ socketRef.current.close();
+ socketRef.current = null;
+ setIsSocketOpen(false);
+ }
+ };
+ }, [isAuthenticated]);
+
+ // Register using a diff with previous calendars
+ useEffect(() => {
+ if (
+ !isSocketOpen ||
+ !socketRef.current ||
+ socketRef.current.readyState !== WebSocket.OPEN
+ )
+ return;
+
+ const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
+ const previousPaths = previousCalendarListRef.current.map(
+ (cal) => `/calendars/${cal}`
+ );
+
+ // calendars to register
+ const toRegister = currentPaths.filter(
+ (path) => !previousPaths.includes(path)
+ );
+
+ // calendars to unregister
+ const toUnregister = previousPaths.filter(
+ (path) => !currentPaths.includes(path)
+ );
+
+ try {
+ if (toRegister.length > 0) {
+ registerToCalendars(socketRef.current, toRegister);
+ }
+
+ if (toUnregister.length > 0) {
+ unregisterToCalendars(socketRef.current, toUnregister);
+ }
+
+ // Only update the ref if operations succeeded
+ previousCalendarListRef.current = calendarList;
+ } catch (error) {
+ console.error("Failed to update calendar registrations:", error);
+ }
+ }, [isSocketOpen, calendarList]);
+
+ return null;
+}
diff --git a/src/websocket/api/fetchWebSocketTicket.ts b/src/websocket/api/fetchWebSocketTicket.ts
new file mode 100644
index 0000000..05c6505
--- /dev/null
+++ b/src/websocket/api/fetchWebSocketTicket.ts
@@ -0,0 +1,12 @@
+import { api } from "../../utils/apiUtils";
+import { WebSocketTicket } from "./types";
+
+export async function fetchWebSocketTicket(): Promise {
+ const response = await api.post("ws/ticket");
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch WebSocket ticket");
+ }
+
+ return response.json();
+}
diff --git a/src/websocket/api/types.ts b/src/websocket/api/types.ts
new file mode 100644
index 0000000..cf1da08
--- /dev/null
+++ b/src/websocket/api/types.ts
@@ -0,0 +1,7 @@
+export interface WebSocketTicket {
+ clientAddress: string;
+ value: string;
+ generatedOn: string;
+ validUntil: string;
+ username: string;
+}
diff --git a/src/websocket/createWebSocketConnection.ts b/src/websocket/createWebSocketConnection.ts
new file mode 100644
index 0000000..339a76e
--- /dev/null
+++ b/src/websocket/createWebSocketConnection.ts
@@ -0,0 +1,107 @@
+import { fetchWebSocketTicket } from "./api/fetchWebSocketTicket";
+import { WS_INBOUND_EVENTS } from "./protocols";
+
+export interface WebSocketWithCleanup extends WebSocket {
+ cleanup: () => void;
+}
+
+export async function createWebSocketConnection(): Promise {
+ const wsBaseUrl =
+ (window as any).WEBSOCKET_URL ??
+ (window as any).CALENDAR_BASE_URL?.replace(
+ /^http(s)?:/,
+ (_: boolean, s: boolean) => (s ? "wss:" : "ws:")
+ ) ??
+ "";
+
+ if (!wsBaseUrl) {
+ throw new Error("WEBSOCKET_URL is not defined");
+ }
+
+ const ticket = await fetchWebSocketTicket();
+
+ const socket = new WebSocket(
+ `${wsBaseUrl}/ws?ticket=${encodeURIComponent(ticket.value)}`
+ );
+
+ const CONNECTION_TIMEOUT_MS = 10_000;
+
+ await new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ socket.removeEventListener(
+ WS_INBOUND_EVENTS.CONNECTION_OPENED,
+ openHandler
+ );
+ socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
+ socket.close();
+ reject(new Error("WebSocket connection timed out"));
+ }, CONNECTION_TIMEOUT_MS);
+ const openHandler = () => {
+ console.log("WebSocket connection opened");
+ clearTimeout(timeoutId);
+ socket.removeEventListener(
+ WS_INBOUND_EVENTS.CONNECTION_OPENED,
+ openHandler
+ );
+ socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
+ resolve();
+ };
+
+ const errorHandler = (error: Event) => {
+ console.error("WebSocket connection failed:", error);
+ clearTimeout(timeoutId);
+ socket.removeEventListener(
+ WS_INBOUND_EVENTS.CONNECTION_OPENED,
+ openHandler
+ );
+ socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
+ reject(new Error("WebSocket connection failed"));
+ };
+
+ socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_OPENED, openHandler);
+ socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
+ });
+
+ // Store references to event handlers so they can be cleaned up later
+ const messageHandler = (event: MessageEvent) => {
+ try {
+ const message = JSON.parse(event.data);
+ console.log("WebSocket message received:", message);
+
+ // TODO: Handle different message types
+ } catch (error) {
+ console.error("Failed to parse WebSocket message:", error);
+ }
+ };
+
+ const closeHandler = (event: CloseEvent) => {
+ console.log("WebSocket closed:", event.code, event.reason);
+ // Clean up all event listeners when socket closes
+ cleanup();
+ // TODO: Add reconnection logic
+ };
+
+ const errorHandler = (error: Event) => {
+ console.error("WebSocket error:", error);
+ };
+
+ // Cleanup function to remove all event listeners
+ const cleanup = () => {
+ socket.removeEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler);
+ socket.removeEventListener(
+ WS_INBOUND_EVENTS.CONNECTION_CLOSED,
+ closeHandler
+ );
+ socket.removeEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
+ };
+
+ socket.addEventListener(WS_INBOUND_EVENTS.MESSAGE, messageHandler);
+ socket.addEventListener(WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeHandler);
+ socket.addEventListener(WS_INBOUND_EVENTS.ERROR, errorHandler);
+
+ // Attach cleanup method to socket
+ const socketWithCleanup = socket as WebSocketWithCleanup;
+ socketWithCleanup.cleanup = cleanup;
+
+ return socketWithCleanup;
+}
diff --git a/src/websocket/protocols.ts b/src/websocket/protocols.ts
new file mode 100644
index 0000000..89eacff
--- /dev/null
+++ b/src/websocket/protocols.ts
@@ -0,0 +1,13 @@
+// WebSocket event listeners (browser events)
+export const WS_INBOUND_EVENTS = {
+ CONNECTION_OPENED: "open",
+ MESSAGE: "message",
+ ERROR: "error",
+ CONNECTION_CLOSED: "close",
+} as const;
+
+// WebSocket message types sent to server
+export const WS_OUTBOUND_EVENTS = {
+ REGISTER_CLIENT: "register",
+ UNREGISTER_CLIENT: "unregister",
+} as const;
diff --git a/src/websocket/ws/registerToCalendars.ts b/src/websocket/ws/registerToCalendars.ts
new file mode 100644
index 0000000..ebdde12
--- /dev/null
+++ b/src/websocket/ws/registerToCalendars.ts
@@ -0,0 +1,16 @@
+export function registerToCalendars(
+ socket: WebSocket,
+ calendarURIList: string[]
+): void {
+ if (socket.readyState !== WebSocket.OPEN) {
+ throw new Error("Cannot register: WebSocket is not open");
+ }
+
+ socket.send(
+ JSON.stringify({
+ register: calendarURIList,
+ })
+ );
+
+ console.log("Registered to calendars", calendarURIList);
+}
diff --git a/src/websocket/ws/unregisterToCalendars.ts b/src/websocket/ws/unregisterToCalendars.ts
new file mode 100644
index 0000000..03a9be1
--- /dev/null
+++ b/src/websocket/ws/unregisterToCalendars.ts
@@ -0,0 +1,16 @@
+export function unregisterToCalendars(
+ socket: WebSocket,
+ calendarURIList: string[]
+): void {
+ if (socket.readyState !== WebSocket.OPEN) {
+ throw new Error("Cannot unregister: WebSocket is not open");
+ }
+
+ socket.send(
+ JSON.stringify({
+ unregister: calendarURIList,
+ })
+ );
+
+ console.log("Unregistered to calendars", calendarURIList);
+}