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