diff --git a/__test__/features/websocket/WebSocketGate.test.tsx b/__test__/features/websocket/WebSocketGate.test.tsx index 6a5891b..7b796a5 100644 --- a/__test__/features/websocket/WebSocketGate.test.tsx +++ b/__test__/features/websocket/WebSocketGate.test.tsx @@ -1,15 +1,15 @@ 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"; +import { createWebSocketConnection } from "@/websocket/connection/createConnection"; +import { registerToCalendars } from "@/websocket/operations/registerToCalendars"; +import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars"; +import { WebSocketGate } from "@/websocket/WebSocketGate"; +import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars"; -jest.mock("../../../src/websocket/createWebSocketConnection"); -jest.mock("../../../src/websocket/ws/registerToCalendars"); -jest.mock("../../../src/websocket/ws/unregisterToCalendars"); +jest.mock("@/websocket/connection/createConnection"); +jest.mock("@/websocket/operations/registerToCalendars"); +jest.mock("@/websocket/operations/unregisterToCalendars"); describe("WebSocketGate", () => { let store: any; @@ -101,7 +101,7 @@ describe("WebSocketGate", () => { }); describe("Socket Connection Management", () => { - it("should add close event listener on socket connection", async () => { + it("should create connection with callbacks", async () => { (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); render( @@ -111,22 +111,25 @@ describe("WebSocketGate", () => { ); await waitFor(() => { - expect(mockSocket.addEventListener).toHaveBeenCalledWith( - "close", - expect.any(Function) + expect(createWebSocketConnection).toHaveBeenCalledWith( + expect.objectContaining({ + onMessage: expect.any(Function), + onClose: expect.any(Function), + onError: expect.any(Function), + }) ); }); }); - it("should handle socket close event", async () => { - let closeHandler: Function; - mockSocket.addEventListener = jest.fn((event, handler) => { - if (event === "close") { - closeHandler = handler; - } - }); + it("should handle socket close via callback", async () => { + let onCloseCallback: Function | undefined; - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + (createWebSocketConnection as jest.Mock).mockImplementation( + (callbacks) => { + onCloseCallback = callbacks.onClose; + return Promise.resolve(mockSocket); + } + ); render( @@ -135,11 +138,12 @@ describe("WebSocketGate", () => { ); await waitFor(() => { - expect(mockSocket.addEventListener).toHaveBeenCalled(); + expect(createWebSocketConnection).toHaveBeenCalled(); }); + // Simulate close event await act(async () => { - closeHandler!(); + onCloseCallback?.(new CloseEvent("close")); }); // Verify that subsequent calendar changes don't try to register @@ -349,7 +353,7 @@ describe("WebSocketGate", () => { await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( - "Failed to update calendar registrations:", + "Failed to register calendar:", expect.any(Error) ); }); @@ -443,8 +447,9 @@ describe("WebSocketGate", () => { }); // Wait a bit to ensure the effect would have run if it was going to - await new Promise((resolve) => setTimeout(resolve, 100)); - + jest.useFakeTimers(); + jest.advanceTimersByTime(100); + jest.useRealTimers(); expect(registerToCalendars).not.toHaveBeenCalled(); }); @@ -477,7 +482,7 @@ describe("WebSocketGate", () => { await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( - "Failed to update calendar registrations:", + "Failed to unregister calendar:", expect.any(Error) ); }); diff --git a/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx b/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx index 7b739d9..142362c 100644 --- a/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx +++ b/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx @@ -1,7 +1,7 @@ -import { api } from "../../../../src/utils/apiUtils"; -import { fetchWebSocketTicket } from "../../../../src/websocket/api/fetchWebSocketTicket"; +import { api } from "@/utils/apiUtils"; +import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket"; -jest.mock("../../../../src/utils/apiUtils"); +jest.mock("@/utils/apiUtils"); describe("fetchWebSocketTicket", () => { const mockTicket = { diff --git a/__test__/features/websocket/createWebSocketConnection.test.tsx b/__test__/features/websocket/createWebSocketConnection.test.tsx index 359a693..7a68fd1 100644 --- a/__test__/features/websocket/createWebSocketConnection.test.tsx +++ b/__test__/features/websocket/createWebSocketConnection.test.tsx @@ -1,10 +1,10 @@ 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 { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket"; +import { createWebSocketConnection } from "@/websocket/connection/createConnection"; +import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; import { setupWebsocket } from "./utils/setupWebsocket"; -jest.mock("../../../src/websocket/api/fetchWebSocketTicket"); +jest.mock("@/websocket/api/fetchWebSocketTicket"); describe("createWebSocketConnection", () => { let mockWebSocket: jest.Mock; @@ -28,7 +28,12 @@ describe("createWebSocketConnection", () => { }; const createAndOpenConnection = async () => { - const promise = createWebSocketConnection(); + const mockCallbacks = { + onMessage: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + }; + const promise = createWebSocketConnection(mockCallbacks); await waitFor(() => { expect(webSocketInstances.length).toBe(1); @@ -37,7 +42,7 @@ describe("createWebSocketConnection", () => { triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED); const socket = await promise; - return { socket, ws: getWs(), promise }; + return { socket, ws: getWs(), promise, mockCallbacks }; }; /** ---------- Setup ---------- */ @@ -60,8 +65,11 @@ describe("createWebSocketConnection", () => { it("throws when WEBSOCKET_URL is not defined", async () => { delete (window as any).WEBSOCKET_URL; + const mockCallbacks = { + onMessage: jest.fn(), + }; - await expect(createWebSocketConnection()).rejects.toThrow( + await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow( "WEBSOCKET_URL is not defined" ); }); @@ -95,7 +103,10 @@ describe("createWebSocketConnection", () => { }); it("rejects when connection fails", async () => { - const promise = createWebSocketConnection(); + const mockCallbacks = { + onMessage: jest.fn(), + }; + const promise = createWebSocketConnection(mockCallbacks); await waitFor(() => { expect(webSocketInstances.length).toBe(1); @@ -118,23 +129,6 @@ describe("createWebSocketConnection", () => { 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(); @@ -149,4 +143,69 @@ describe("createWebSocketConnection", () => { errorSpy.mockRestore(); }); + + it("rejects on timeout", async () => { + jest.useFakeTimers(); + const mockCallbacks = { + onMessage: jest.fn(), + }; + const promise = createWebSocketConnection(mockCallbacks); + + await waitFor(() => { + expect(webSocketInstances.length).toBe(1); + }); + + jest.advanceTimersByTime(10000); + + await expect(promise).rejects.toThrow("WebSocket connection timed out"); + + jest.useRealTimers(); + }); + + it("calls onMessage callback when message received", async () => { + const { ws, mockCallbacks } = await createAndOpenConnection(); + + const testMessage = { type: "test", payload: "data" }; + triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { + data: JSON.stringify(testMessage), + }); + + expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage); + }); + + it("does not call onMessage when JSON parsing fails", async () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(); + const { ws, mockCallbacks } = await createAndOpenConnection(); + + triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" }); + + expect(mockCallbacks.onMessage).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to parse WebSocket message:", + expect.any(Error) + ); + + errorSpy.mockRestore(); + }); + + it("calls onClose callback when connection closes", async () => { + const { ws, mockCallbacks } = await createAndOpenConnection(); + + const closeEvent = new CloseEvent("close", { + code: 1000, + reason: "Normal closure", + }); + triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent); + + expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent); + }); + + it("calls onError callback when error occurs", async () => { + const { ws, mockCallbacks } = await createAndOpenConnection(); + + const errorEvent = new Event("error"); + triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent); + + expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent); + }); }); diff --git a/__test__/features/websocket/utils/updateCalendars.test.tsx b/__test__/features/websocket/utils/updateCalendars.test.tsx new file mode 100644 index 0000000..8ffb7ee --- /dev/null +++ b/__test__/features/websocket/utils/updateCalendars.test.tsx @@ -0,0 +1,127 @@ +import { updateCalendars } from "@/websocket/messaging/updateCalendars"; +import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar"; +import { RootState, store } from "@/app/store"; +import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; +import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager"; +import { waitFor } from "@testing-library/dom"; + +jest.mock("@/features/Calendars/services/refreshCalendar"); +jest.mock("@/utils/CalendarRangeManager"); +jest.mock("@/app/store", () => ({ + store: { + getState: jest.fn(), + }, +})); + +describe("updateCalendars", () => { + let mockDispatch: jest.Mock; + const mockRange = { + start: new Date("2025-01-15T10:00:00Z"), + end: new Date("2025-01-16T10:00:00Z"), + }; + + const mockState = { + calendars: { + list: { + "cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 }, + "cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 }, + }, + templist: {}, + }, + } as unknown as RootState; + + beforeEach(() => { + jest.clearAllMocks(); + mockDispatch = jest.fn(); + (getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); + (store.getState as jest.Mock).mockReturnValue(mockState); + }); + + it("should not dispatch for non-object messages", () => { + updateCalendars(null, mockDispatch); + updateCalendars("string", mockDispatch); + updateCalendars(123, mockDispatch); + + expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled(); + }); + + it("should dispatch for registered calendars", () => { + const message = { + [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ + "/calendars/cal1/entry1", + "/calendars/cal2/entry2", + ], + }; + + updateCalendars(message, mockDispatch); + + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2); + }); + + it("should dispatch for calendar path updates", () => { + const message = { + "/calendars/cal1/entry1": { updated: true }, + }; + + updateCalendars(message, mockDispatch); + + expect(refreshCalendarWithSyncToken).toHaveBeenCalled(); + expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ + calendar: mockState.calendars.list["cal1/entry1"], + calType: undefined, + calendarRange: mockRange, + }); + }); + + it("should use current displayed calendar range", () => { + const message = { + "/calendars/cal1/entry1": {}, + }; + + updateCalendars(message, mockDispatch); + + expect(getDisplayedCalendarRange).toHaveBeenCalled(); + }); + + it("should handle temp calendars", async () => { + const stateWithTemp = { + calendars: { + list: {}, + templist: { + "temp1/entry1": { + id: "temp1/entry1", + name: "Temp Calendar", + syncToken: 1, + }, + }, + }, + }; + + (store.getState as jest.Mock).mockReturnValue(stateWithTemp); + + const message = { + "/calendars/temp1/entry1": {}, + }; + + updateCalendars(message, mockDispatch); + + await waitFor(() => + expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ + calendar: stateWithTemp.calendars.templist["temp1/entry1"], + calType: "temp", + calendarRange: mockRange, + }) + ); + }); + + it("should handle invalid calendar paths gracefully", () => { + const message = { + "/invalid/path": {}, + "not-a-path": {}, + }; + + updateCalendars(message, mockDispatch); + + expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled(); + }); +}); diff --git a/__test__/features/websocket/websocketAPI/parseMessage.test.ts b/__test__/features/websocket/websocketAPI/parseMessage.test.ts new file mode 100644 index 0000000..ed1f75f --- /dev/null +++ b/__test__/features/websocket/websocketAPI/parseMessage.test.ts @@ -0,0 +1,76 @@ +import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; +import { parseMessage } from "@/websocket/messaging/parseMessage"; + +describe("parseMessage", () => { + it("should return empty set for non-object messages", () => { + const result1 = parseMessage(null); + const result2 = parseMessage("string"); + const result3 = parseMessage(123); + + expect(result1.calendarsToRefresh).toEqual(new Set()); + expect(result2.calendarsToRefresh).toEqual(new Set()); + expect(result3.calendarsToRefresh).toEqual(new Set()); + }); + + it("should handle registered event", () => { + const message = { + [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ + "/calendars/cal1/entry1", + "/calendars/cal2/entry2", + ], + }; + + const result = parseMessage(message); + + expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); + expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2"); + expect(result.calendarsToRefresh.size).toBe(2); + }); + + it("should handle unregistered event", () => { + const message = { + [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal1/entry1"], + }; + + const result = parseMessage(message); + + expect(result.calendarsToHide).toContain("/calendars/cal1/entry1"); + }); + + it("should handle calendar path updates", () => { + const message = { + "/calendars/cal1/entry1": { updated: true }, + }; + + const result = parseMessage(message); + + expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); + expect(result.calendarsToRefresh.size).toBe(1); + }); + + it("should parse multiple calendar paths", () => { + const message = { + "/calendars/cal1/entry1": {}, + "/calendars/cal2/entry2": {}, + }; + + const result = parseMessage(message); + + expect(result.calendarsToRefresh.size).toBe(2); + expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); + expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2"); + }); + + it("should handle multiple event types in single message", () => { + const message = { + [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ["/calendars/cal1/entry1"], + [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal2/entry2"], + "/calendars/cal1/entry1": {}, + }; + + const result = parseMessage(message); + + expect(result.calendarsToRefresh.size).toBe(1); + expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); + }); +}); diff --git a/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx b/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx index 8202192..2518bc4 100644 --- a/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx +++ b/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx @@ -1,4 +1,4 @@ -import { registerToCalendars } from "../../../../src/websocket/ws/registerToCalendars"; +import { registerToCalendars } from "@/websocket/operations/registerToCalendars"; describe("registerToCalendars", () => { let mockSocket: any; diff --git a/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx b/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx index 1525f42..9052725 100644 --- a/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx +++ b/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx @@ -1,6 +1,6 @@ // src/websocket/__tests__/unregisterToCalendars.test.ts -import { unregisterToCalendars } from "../../../../src/websocket/ws/unregisterToCalendars"; +import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars"; describe("unregisterToCalendars", () => { let mockSocket: any; diff --git a/__test__/utils/CalendarRangeManager.test.ts b/__test__/utils/CalendarRangeManager.test.ts new file mode 100644 index 0000000..c730cd9 --- /dev/null +++ b/__test__/utils/CalendarRangeManager.test.ts @@ -0,0 +1,73 @@ +import { + getDisplayedDate, + setDisplayedDateAndRange, + calendarRangeManager, + getDisplayedCalendarRange, +} from "../../src/utils/CalendarRangeManager"; + +describe("CalendarRangeManager", () => { + beforeEach(() => { + setDisplayedDateAndRange(new Date()); + }); + + it("should return a singleton instance", () => { + const instance1 = calendarRangeManager; + const instance2 = calendarRangeManager; + + expect(instance1).toStrictEqual(instance2); + }); + + it("should compute and return calendar range when date is set", () => { + const testDate = new Date("2025-06-15T10:00:00Z"); + setDisplayedDateAndRange(testDate); + + const range = getDisplayedCalendarRange(); + expect(range.start).toBeInstanceOf(Date); + expect(range.end).toBeInstanceOf(Date); + expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime()); + expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime()); + }); + + it("should get the default date", () => { + const date = getDisplayedDate(); + expect(date).toBeInstanceOf(Date); + }); + + it("should set and get a date", () => { + const testDate = new Date("2025-01-15T10:00:00Z"); + setDisplayedDateAndRange(testDate); + + const retrievedDate = getDisplayedDate(); + expect(retrievedDate).toStrictEqual(testDate); + }); + + it("should persist date across multiple calls", () => { + const testDate = new Date("2025-06-20T15:30:00Z"); + setDisplayedDateAndRange(testDate); + + const date1 = getDisplayedDate(); + const date2 = getDisplayedDate(); + + expect(date1).toStrictEqual(date2); + expect(date1).toStrictEqual(testDate); + }); + + it("should update date when set multiple times", () => { + const date1 = new Date("2025-01-01T00:00:00Z"); + const date2 = new Date("2025-12-31T23:59:59Z"); + + setDisplayedDateAndRange(date1); + expect(getDisplayedDate()).toStrictEqual(date1); + + setDisplayedDateAndRange(date2); + expect(getDisplayedDate()).toStrictEqual(date2); + }); + + it("should maintain shared state (singleton behavior)", () => { + const testDate = new Date("2025-03-01"); + setDisplayedDateAndRange(testDate); + + // Verify CalendarRangeManager reflects the same state + expect(calendarRangeManager.getDate()).toStrictEqual(testDate); + }); +}); diff --git a/__test__/utils/findCalendarById.test.ts b/__test__/utils/findCalendarById.test.ts new file mode 100644 index 0000000..d777302 --- /dev/null +++ b/__test__/utils/findCalendarById.test.ts @@ -0,0 +1,99 @@ +import { findCalendarById } from "../../src/utils/findCalendarById"; +import { Calendar } from "../../src/features/Calendars/CalendarTypes"; +import { RootState } from "../../src/app/store"; + +describe("findCalendarById", () => { + const mockCalendar1: Calendar = { + id: "cal1", + name: "Personal", + color: { light: "#FF0000" }, + } as unknown as Calendar; + + const mockCalendar2: Calendar = { + id: "cal2", + name: "Work", + color: { light: "#0000FF" }, + } as unknown as Calendar; + + const mockTempCalendar: Calendar = { + id: "temp1", + name: "Temporary", + color: { light: "#00FF00" }, + } as unknown as Calendar; + + const mockState = { + calendars: { + list: { + cal1: mockCalendar1, + cal2: mockCalendar2, + }, + templist: { + temp1: mockTempCalendar, + }, + }, + } as unknown as Partial; + + it("should find calendar in main list", () => { + const result = findCalendarById(mockState, "cal1"); + + expect(result?.calendar).toEqual(mockCalendar1); + expect(result?.type).toBeUndefined(); + }); + + it("should find calendar in temp list", () => { + const result = findCalendarById(mockState, "temp1"); + + expect(result?.calendar).toEqual(mockTempCalendar); + expect(result?.type).toBe("temp"); + }); + + it("should not return calendar for non-existent id", () => { + const result = findCalendarById(mockState, "nonexistent"); + + expect(result).toBeUndefined(); + }); + + it("should not return calendar for empty string", () => { + const result = findCalendarById(mockState, ""); + + expect(result).toBeUndefined(); + }); + + it("should prioritize main list over temp list", () => { + const stateWithDuplicate = { + calendars: { + list: { + dup1: mockCalendar1, + }, + templist: { + dup1: mockTempCalendar, + }, + }, + } as unknown as Partial; + + const result = findCalendarById(stateWithDuplicate, "dup1"); + + expect(result?.calendar).toEqual(mockCalendar1); + expect(result?.type).toBeUndefined(); + }); + + it("should handle undefined list or templist", () => { + const stateWithPartialCalendars = { + calendars: { + list: undefined, + templist: { temp1: mockTempCalendar }, + }, + } as unknown as Partial; + + const result = findCalendarById(stateWithPartialCalendars, "temp1"); + expect(result?.calendar).toEqual(mockTempCalendar); + }); + + it("should handle missing calendars state", () => { + const emptyState = {}; + + const result = findCalendarById(emptyState, "cal1"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/jest.config.ts b/jest.config.ts index 1d3dfb3..62e3e5d 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -42,6 +42,7 @@ const config: Config = { "^preact(/(.*)|$)": "preact$1", "^react$": "/node_modules/react", "^react-dom$": "/node_modules/react-dom", + "^@/(.*)$": "/src/$1", }, setupFilesAfterEnv: ["/src/setupTests.ts"], }, @@ -67,6 +68,9 @@ const config: Config = { }, transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"], setupFilesAfterEnv: ["/src/setupTests.ts"], + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + }, }, ], }; diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 314181c..b6ee46c 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -15,7 +15,7 @@ import ImportAlert from "../../features/Events/ImportAlert"; import { formatDateToYYYYMMDDTHHMMSS, getCalendarRange, -} from "../../utils/dateUtils"; +} from "@/utils/dateUtils"; import { push } from "redux-first-history"; import EventPreviewModal from "../../features/Events/EventDisplayPreview"; import AddIcon from "@mui/icons-material/Add"; @@ -43,9 +43,10 @@ import ruLocale from "@fullcalendar/core/locales/ru"; import viLocale from "@fullcalendar/core/locales/vi"; 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"; +import { browserDefaultTimeZone } from "@/utils/timezone"; +import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars"; +import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager"; const localeMap: Record = { fr: frLocale, @@ -741,6 +742,7 @@ export default function CalendarApp({ setCurrentView(arg.view.type); const calendarCurrentDate = calendarRef.current?.getDate() || new Date(arg.start); + setDisplayedDateAndRange(calendarCurrentDate); if (arg.view.type === "dayGridMonth") { const start = new Date(arg.start).getTime(); diff --git a/src/utils/CalendarRangeManager.ts b/src/utils/CalendarRangeManager.ts new file mode 100644 index 0000000..ed052ff --- /dev/null +++ b/src/utils/CalendarRangeManager.ts @@ -0,0 +1,44 @@ +import { getCalendarRange } from "./dateUtils"; + +class CalendarRangeManager { + private static instance: CalendarRangeManager; + private displayedDate: Date = new Date(); + private displayedCalendarRange: { start: Date; end: Date } = getCalendarRange( + new Date() + ); + + private constructor() {} + + public static getInstance(): CalendarRangeManager { + if (!CalendarRangeManager.instance) { + CalendarRangeManager.instance = new CalendarRangeManager(); + } + return CalendarRangeManager.instance; + } + + public getDate(): Date { + return new Date(this.displayedDate); + } + + public setDate(date: Date) { + this.displayedDate = new Date(date); + this.displayedCalendarRange = getCalendarRange(new Date(date)); + } + + public getRange(): { start: Date; end: Date } { + return { + start: this.displayedCalendarRange.start, + end: this.displayedCalendarRange.end, + }; + } +} + +export const calendarRangeManager = CalendarRangeManager.getInstance(); + +export const getDisplayedDate = (): Date => calendarRangeManager.getDate(); + +export const setDisplayedDateAndRange = (date: Date) => + calendarRangeManager.setDate(date); + +export const getDisplayedCalendarRange = (): { start: Date; end: Date } => + calendarRangeManager.getRange(); diff --git a/src/utils/findCalendarById.ts b/src/utils/findCalendarById.ts new file mode 100644 index 0000000..fb48bd9 --- /dev/null +++ b/src/utils/findCalendarById.ts @@ -0,0 +1,17 @@ +import { RootState } from "../app/store"; +import { Calendar } from "../features/Calendars/CalendarTypes"; + +export function findCalendarById( + state: Partial, + calendarId: string +): { calendar: Calendar; type?: "temp" } | undefined { + if (calendarId.length === 0) { + return; + } + if (state.calendars?.list?.[calendarId]) { + return { calendar: state.calendars.list[calendarId] }; + } + if (state.calendars?.templist?.[calendarId]) { + return { calendar: state.calendars.templist[calendarId], type: "temp" }; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..8eda139 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,8 @@ +export { + getDisplayedDate, + setDisplayedDateAndRange, + getDisplayedCalendarRange, + calendarRangeManager, +} from "./CalendarRangeManager"; + +export { findCalendarById } from "./findCalendarById"; diff --git a/src/websocket/WebSocketGate.tsx b/src/websocket/WebSocketGate.tsx index 91036ba..d7d8baa 100644 --- a/src/websocket/WebSocketGate.tsx +++ b/src/websocket/WebSocketGate.tsx @@ -1,103 +1,79 @@ -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"; +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { WebSocketWithCleanup } from "./connection"; +import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection"; +import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection"; +import { updateCalendars } from "./messaging"; +import { syncCalendarRegistrations } from "./operations"; export function WebSocketGate() { const socketRef = useRef(null); const previousCalendarListRef = useRef([]); - + const dispatch = useAppDispatch(); const isAuthenticated = useAppSelector((state) => Boolean(state.user.userData && state.user.tokens) ); + const [isSocketOpen, setIsSocketOpen] = useState(false); const calendarList = useSelectedCalendars(); + const onMessage = useCallback( + (message: unknown) => { + updateCalendars(message, dispatch); + }, + [dispatch] + ); + + const onClose = useCallback((event: CloseEvent) => { + // Socket already cleaned up by internal handler before this callback fires + socketRef.current = null; + setIsSocketOpen(false); + // TODO: Add reconnection logic here + }, []); + + const onError = useCallback((error: Event) => { + console.error("WebSocket error:", error); + }, []); + + const callBacks = useMemo( + () => ({ + onMessage, + onClose, + onError, + }), + [onMessage, onClose, onError] + ); // Manage WebSocket connection useEffect(() => { + const abortController = new AbortController(); if (!isAuthenticated) { - if (socketRef.current) { - socketRef.current.cleanup(); - socketRef.current.close(); - socketRef.current = null; - setIsSocketOpen(false); - } + closeWebSocketConnection(socketRef, setIsSocketOpen); 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(); + establishWebSocketConnection( + callBacks, + socketRef, + setIsSocketOpen, + abortController.signal + ); return () => { - if (socketRef.current) { - socketRef.current.cleanup(); - socketRef.current.close(); - socketRef.current = null; - setIsSocketOpen(false); - } + abortController.abort(); + closeWebSocketConnection(socketRef, setIsSocketOpen); }; - }, [isAuthenticated]); + }, [isAuthenticated, callBacks]); // 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}` + syncCalendarRegistrations( + isSocketOpen, + socketRef, + calendarList, + previousCalendarListRef ); - - // 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/createWebSocketConnection.ts b/src/websocket/connection/createConnection.ts similarity index 84% rename from src/websocket/createWebSocketConnection.ts rename to src/websocket/connection/createConnection.ts index 339a76e..18b92de 100644 --- a/src/websocket/createWebSocketConnection.ts +++ b/src/websocket/connection/createConnection.ts @@ -1,16 +1,15 @@ -import { fetchWebSocketTicket } from "./api/fetchWebSocketTicket"; -import { WS_INBOUND_EVENTS } from "./protocols"; +import { fetchWebSocketTicket } from "../api/fetchWebSocketTicket"; +import { WS_INBOUND_EVENTS } from "../protocols"; +import { WebSocketCallbacks, WebSocketWithCleanup } from "./types"; -export interface WebSocketWithCleanup extends WebSocket { - cleanup: () => void; -} - -export async function createWebSocketConnection(): Promise { +export async function createWebSocketConnection( + callbacks: WebSocketCallbacks +): Promise { const wsBaseUrl = (window as any).WEBSOCKET_URL ?? (window as any).CALENDAR_BASE_URL?.replace( /^http(s)?:/, - (_: boolean, s: boolean) => (s ? "wss:" : "ws:") + (_: string, s: string | undefined) => (s ? "wss:" : "ws:") ) ?? ""; @@ -36,6 +35,7 @@ export async function createWebSocketConnection(): Promise socket.close(); reject(new Error("WebSocket connection timed out")); }, CONNECTION_TIMEOUT_MS); + const openHandler = () => { console.log("WebSocket connection opened"); clearTimeout(timeoutId); @@ -66,9 +66,7 @@ export async function createWebSocketConnection(): Promise const messageHandler = (event: MessageEvent) => { try { const message = JSON.parse(event.data); - console.log("WebSocket message received:", message); - - // TODO: Handle different message types + callbacks.onMessage(message); } catch (error) { console.error("Failed to parse WebSocket message:", error); } @@ -76,13 +74,13 @@ export async function createWebSocketConnection(): Promise 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 + callbacks.onClose?.(event); }; const errorHandler = (error: Event) => { console.error("WebSocket error:", error); + callbacks.onError?.(error); }; // Cleanup function to remove all event listeners diff --git a/src/websocket/connection/index.ts b/src/websocket/connection/index.ts new file mode 100644 index 0000000..90fe600 --- /dev/null +++ b/src/websocket/connection/index.ts @@ -0,0 +1,2 @@ +export { createWebSocketConnection } from "./createConnection"; +export type { WebSocketWithCleanup, WebSocketCallbacks } from "./types"; diff --git a/src/websocket/connection/lifecycle/closeWebSocketConnection.ts b/src/websocket/connection/lifecycle/closeWebSocketConnection.ts new file mode 100644 index 0000000..a54aace --- /dev/null +++ b/src/websocket/connection/lifecycle/closeWebSocketConnection.ts @@ -0,0 +1,13 @@ +import { WebSocketWithCleanup } from "../types"; + +export function closeWebSocketConnection( + socketRef: React.MutableRefObject, + setIsSocketOpen: (value: boolean) => void +) { + if (socketRef.current) { + socketRef.current.cleanup(); + socketRef.current.close(); + socketRef.current = null; + setIsSocketOpen(false); + } +} diff --git a/src/websocket/connection/lifecycle/establishWebSocketConnection.ts b/src/websocket/connection/lifecycle/establishWebSocketConnection.ts new file mode 100644 index 0000000..e1f1544 --- /dev/null +++ b/src/websocket/connection/lifecycle/establishWebSocketConnection.ts @@ -0,0 +1,28 @@ +import { createWebSocketConnection } from "../createConnection"; +import { WebSocketCallbacks, WebSocketWithCleanup } from "../types"; + +export async function establishWebSocketConnection( + callbacks: WebSocketCallbacks, + socketRef: React.MutableRefObject, + setIsSocketOpen: (value: boolean) => void, + signal?: AbortSignal +) { + try { + const socket = await createWebSocketConnection(callbacks); + + if (signal?.aborted) { + socket.cleanup(); + socket.close(); + return; + } + + socketRef.current = socket; + + if (socket.readyState === WebSocket.OPEN) { + setIsSocketOpen(true); + } + } catch (error) { + console.error("Failed to create WebSocket connection:", error); + setIsSocketOpen(false); + } +} diff --git a/src/websocket/connection/types.ts b/src/websocket/connection/types.ts new file mode 100644 index 0000000..242547e --- /dev/null +++ b/src/websocket/connection/types.ts @@ -0,0 +1,9 @@ +export interface WebSocketWithCleanup extends WebSocket { + cleanup: () => void; +} + +export interface WebSocketCallbacks { + onMessage: (data: unknown) => void; + onClose?: (event: CloseEvent) => void; + onError?: (error: Event) => void; +} diff --git a/src/websocket/index.ts b/src/websocket/index.ts new file mode 100644 index 0000000..3ea02db --- /dev/null +++ b/src/websocket/index.ts @@ -0,0 +1,2 @@ +export { WebSocketGate } from "./WebSocketGate"; +export type { WebSocketWithCleanup, WebSocketCallbacks } from "./connection"; diff --git a/src/websocket/messaging/index.ts b/src/websocket/messaging/index.ts new file mode 100644 index 0000000..17dc04a --- /dev/null +++ b/src/websocket/messaging/index.ts @@ -0,0 +1,3 @@ +export { parseMessage } from "./parseMessage"; +export { updateCalendars } from "./updateCalendars"; +export { parseCalendarPath } from "./parseCalendarPath"; diff --git a/src/websocket/messaging/parseCalendarPath.ts b/src/websocket/messaging/parseCalendarPath.ts new file mode 100644 index 0000000..c2de9cc --- /dev/null +++ b/src/websocket/messaging/parseCalendarPath.ts @@ -0,0 +1,11 @@ +const CALENDAR_PATH_REGEX = /^\/calendars\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/; + +export function parseCalendarPath(key: string) { + if (!CALENDAR_PATH_REGEX.test(key)) { + return null; + } + + const [, , userId, calendarId] = key.split("/"); + + return `${userId}/${calendarId}`; +} diff --git a/src/websocket/messaging/parseMessage.ts b/src/websocket/messaging/parseMessage.ts new file mode 100644 index 0000000..9754ff6 --- /dev/null +++ b/src/websocket/messaging/parseMessage.ts @@ -0,0 +1,30 @@ +import { WS_INBOUND_EVENTS } from "../protocols"; + +export function parseMessage(message: unknown) { + console.log("WebSocket message received:", message); + const calendarsToRefresh = new Set(); + const calendarsToHide = new Set(); + if (typeof message !== "object" || message === null) { + return { calendarsToRefresh, calendarsToHide }; + } + + for (const [key, value] of Object.entries(message)) { + switch (key) { + case WS_INBOUND_EVENTS.CLIENT_REGISTERED: + if (Array.isArray(value)) { + value.forEach((cal: string) => calendarsToRefresh.add(cal)); + } + break; + case WS_INBOUND_EVENTS.CLIENT_UNREGISTERED: + if (Array.isArray(value)) { + value.forEach((cal: string) => calendarsToHide.add(cal)); + } + break; + default: { + calendarsToRefresh.add(key); + } + } + } + + return { calendarsToRefresh, calendarsToHide }; +} diff --git a/src/websocket/messaging/updateCalendars.ts b/src/websocket/messaging/updateCalendars.ts new file mode 100644 index 0000000..3c07419 --- /dev/null +++ b/src/websocket/messaging/updateCalendars.ts @@ -0,0 +1,45 @@ +import type { AppDispatch } from "@/app/store"; +import { store } from "@/app/store"; +import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar"; +import { findCalendarById, getDisplayedCalendarRange } from "@/utils"; +import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars"; +import { parseCalendarPath } from "./parseCalendarPath"; +import { parseMessage } from "./parseMessage"; + +export function updateCalendars(message: unknown, dispatch: AppDispatch) { + const currentRange = getDisplayedCalendarRange(); + const state = store.getState(); + const { calendarsToRefresh, calendarsToHide } = parseMessage(message); + calendarsToRefresh.forEach((calendarPath) => { + const calendarId = parseCalendarPath(calendarPath); + if (!calendarId) { + console.warn("Invalid calendar path received:", calendarPath); + return; + } + const calendar = findCalendarById(state, calendarId); + if (calendar) { + dispatch( + refreshCalendarWithSyncToken({ + calendar: calendar.calendar, + calType: calendar.type, + calendarRange: currentRange, + }) + ); + } else { + console.warn("Calendar not found for id:", calendarId); + } + }); + const currentSelectedCalendars = JSON.parse( + localStorage.getItem("selectedCalendars") ?? "[]" + ) as string[]; + + const calendarIdsToHide = [...calendarsToHide] + .map(parseCalendarPath) + .filter((id): id is string => Boolean(id)); + + const updatedSelectedCalendars = currentSelectedCalendars.filter( + (id) => !calendarIdsToHide.includes(id) + ); + + setSelectedCalendars(updatedSelectedCalendars); +} diff --git a/src/websocket/operations/index.ts b/src/websocket/operations/index.ts new file mode 100644 index 0000000..a088c22 --- /dev/null +++ b/src/websocket/operations/index.ts @@ -0,0 +1,3 @@ +export { registerToCalendars } from "./registerToCalendars"; +export { unregisterToCalendars } from "./unregisterToCalendars"; +export { syncCalendarRegistrations } from "./syncCalendarRegistrations"; diff --git a/src/websocket/ws/registerToCalendars.ts b/src/websocket/operations/registerToCalendars.ts similarity index 100% rename from src/websocket/ws/registerToCalendars.ts rename to src/websocket/operations/registerToCalendars.ts diff --git a/src/websocket/operations/syncCalendarRegistrations.ts b/src/websocket/operations/syncCalendarRegistrations.ts new file mode 100644 index 0000000..65e60e8 --- /dev/null +++ b/src/websocket/operations/syncCalendarRegistrations.ts @@ -0,0 +1,48 @@ +import { WebSocketWithCleanup } from "../connection"; +import { registerToCalendars } from "./registerToCalendars"; +import { unregisterToCalendars } from "./unregisterToCalendars"; + +export function syncCalendarRegistrations( + isSocketOpen: boolean, + socketRef: React.MutableRefObject, + calendarList: string[], + previousCalendarListRef: React.MutableRefObject +) { + 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}` + ); + + const toRegister = currentPaths.filter( + (path) => !previousPaths.includes(path) + ); + const toUnregister = previousPaths.filter( + (path) => !currentPaths.includes(path) + ); + + try { + if (toRegister.length > 0) { + registerToCalendars(socketRef.current, toRegister); + } + } catch (error) { + console.error("Failed to register calendar:", error); + return; + } + try { + if (toUnregister.length > 0) { + unregisterToCalendars(socketRef.current, toUnregister); + } + } catch (error) { + console.error("Failed to unregister calendar:", error); + return; + } + previousCalendarListRef.current = calendarList; +} diff --git a/src/websocket/ws/unregisterToCalendars.ts b/src/websocket/operations/unregisterToCalendars.ts similarity index 100% rename from src/websocket/ws/unregisterToCalendars.ts rename to src/websocket/operations/unregisterToCalendars.ts diff --git a/src/websocket/protocols.ts b/src/websocket/protocols.ts deleted file mode 100644 index 89eacff..0000000 --- a/src/websocket/protocols.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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/protocols/index.ts b/src/websocket/protocols/index.ts new file mode 100644 index 0000000..d62a18b --- /dev/null +++ b/src/websocket/protocols/index.ts @@ -0,0 +1,8 @@ +export const WS_INBOUND_EVENTS = { + CONNECTION_OPENED: "open", + MESSAGE: "message", + ERROR: "error", + CONNECTION_CLOSED: "close", + CLIENT_REGISTERED: "registered", + CLIENT_UNREGISTERED: "unregistered", +} as const; diff --git a/tsconfig.json b/tsconfig.json index 2ebfc57..7eee235 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,16 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@/websocket": ["src/websocket/index.ts"], + "@/websocket/*": ["src/websocket/*"], + "@/utils": ["src/utils/index.ts"], + "@/features/*": ["src/features/*"], + "@/app/*": ["src/app/*"] + } }, "include": ["src", "__test__"] }