* [#451] added websocket connexion * [#451] added tests * [#451] add missing cleanup for web socket causing memory leaks
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,3 +10,4 @@ var MAIL_SPA_URL = "https://mail.example.com";
|
|||||||
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com";
|
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com";
|
||||||
var DEBUG = false;
|
var DEBUG = false;
|
||||||
var LANG = "en";
|
var LANG = "en";
|
||||||
|
var WEBSOCKET_URL = "wss://calendar.example.com";
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import fr from "./locales/fr.json";
|
|||||||
import ru from "./locales/ru.json";
|
import ru from "./locales/ru.json";
|
||||||
import vi from "./locales/vi.json";
|
import vi from "./locales/vi.json";
|
||||||
import I18n from "twake-i18n";
|
import I18n from "twake-i18n";
|
||||||
|
import { WebSocketGate } from "./websocket/WebSocketGate";
|
||||||
|
|
||||||
const locale = { en, fr, ru, vi };
|
const locale = { en, fr, ru, vi };
|
||||||
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
|
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
|
||||||
@@ -68,6 +69,7 @@ function App() {
|
|||||||
locales={dateLocales}
|
locales={dateLocales}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
|
<WebSocketGate />
|
||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HandleLogin />} />
|
<Route path="/" element={<HandleLogin />} />
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
|||||||
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
||||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "../../utils/storage/setSelectedCalendars";
|
||||||
|
|
||||||
const localeMap: Record<string, any> = {
|
const localeMap: Record<string, any> = {
|
||||||
fr: frLocale,
|
fr: frLocale,
|
||||||
@@ -164,10 +165,7 @@ export default function CalendarApp({
|
|||||||
// Save selected cals to cache
|
// Save selected cals to cache
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (calendarIds.length > 0) {
|
if (calendarIds.length > 0) {
|
||||||
localStorage.setItem(
|
setSelectedCalendarsToStorage(selectedCalendars);
|
||||||
"selectedCalendars",
|
|
||||||
JSON.stringify(selectedCalendars)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [selectedCalendars, calendarIds.length]);
|
}, [selectedCalendars, calendarIds.length]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useSelectedCalendars(): string[] {
|
||||||
|
const [calendars, setCalendars] = useState<string[]>(() => {
|
||||||
|
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<string[]>) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<WebSocketWithCleanup | null>(null);
|
||||||
|
const previousCalendarListRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { api } from "../../utils/apiUtils";
|
||||||
|
import { WebSocketTicket } from "./types";
|
||||||
|
|
||||||
|
export async function fetchWebSocketTicket(): Promise<WebSocketTicket> {
|
||||||
|
const response = await api.post("ws/ticket");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch WebSocket ticket");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface WebSocketTicket {
|
||||||
|
clientAddress: string;
|
||||||
|
value: string;
|
||||||
|
generatedOn: string;
|
||||||
|
validUntil: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
@@ -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<WebSocketWithCleanup> {
|
||||||
|
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<void>((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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user