444 use websocket to trigger reloads (#458)

* [#444] added refresh on sync token calendar update

* [#444] added refresh on register and tests

* [#444] extracted logic for useEffects and updates

* [#444] refactored code structure to use absolute paths
This commit is contained in:
Camille Moussu
2026-01-19 09:09:23 +01:00
committed by GitHub
parent 80110bdf52
commit 00c3c0d6f0
32 changed files with 849 additions and 163 deletions
@@ -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(
<Provider store={store}>
@@ -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)
);
});
@@ -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 = {
@@ -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);
});
});
@@ -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();
});
});
@@ -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<string>());
expect(result2.calendarsToRefresh).toEqual(new Set<string>());
expect(result3.calendarsToRefresh).toEqual(new Set<string>());
});
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");
});
});
@@ -1,4 +1,4 @@
import { registerToCalendars } from "../../../../src/websocket/ws/registerToCalendars";
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
describe("registerToCalendars", () => {
let mockSocket: any;
@@ -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;
@@ -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);
});
});
+99
View File
@@ -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<RootState>;
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<RootState>;
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<RootState>;
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();
});
});