feat: Migrate from Socket.IO to Server-Sent Events (SSE)
- Replace Socket.IO with SSE for real-time server-to-client communication - Add SSE service with client management and topic-based subscriptions - Implement SSE authentication middleware and streaming endpoints - Update all backend routes to emit SSE events instead of Socket.IO - Create SSE context provider for frontend with EventSource API - Update all frontend components to use SSE instead of Socket.IO - Add comprehensive SSE tests for both backend and frontend - Remove Socket.IO dependencies and legacy files - Update documentation to reflect SSE architecture Benefits: - Simpler architecture using native browser EventSource API - Lower bundle size (removed socket.io-client dependency) - Better compatibility with reverse proxies and load balancers - Reduced resource usage for Raspberry Pi deployment - Standard HTTP-based real-time communication 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
+3
-3
@@ -5,7 +5,7 @@ import "react-toastify/dist/ReactToastify.css";
|
||||
import "./styles/toastStyles.css";
|
||||
|
||||
import AuthProvider from "./context/AuthContext";
|
||||
import SocketProvider from "./context/SocketContext";
|
||||
import SSEProvider from "./context/SSEContext";
|
||||
import NotificationProvider from "./context/NotificationProvider";
|
||||
import Login from "./components/Login";
|
||||
import Register from "./components/Register";
|
||||
@@ -24,7 +24,7 @@ import PrivateRoute from "./components/PrivateRoute";
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<SSEProvider>
|
||||
<NotificationProvider>
|
||||
<Router>
|
||||
<Navbar />
|
||||
@@ -59,7 +59,7 @@ function App() {
|
||||
/>
|
||||
</Router>
|
||||
</NotificationProvider>
|
||||
</SocketProvider>
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,15 +27,13 @@ jest.mock('react-leaflet', () => ({
|
||||
|
||||
|
||||
|
||||
// Mock Socket.IO
|
||||
jest.mock('socket.io-client', () => {
|
||||
return jest.fn(() => ({
|
||||
on: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
off: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
});
|
||||
// Mock EventSource for SSE
|
||||
global.EventSource = jest.fn(() => ({
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
close: jest.fn(),
|
||||
readyState: 1,
|
||||
}));
|
||||
|
||||
describe('Authentication Flow Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { toast } from "react-toastify";
|
||||
import NotificationProvider, { notify } from "../../context/NotificationProvider";
|
||||
import { SocketContext } from "../../context/SocketContext";
|
||||
import { SSEContext } from "../../context/SSEContext";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
|
||||
// Mock axios to prevent import errors
|
||||
@@ -20,25 +20,21 @@ jest.mock("react-toastify", () => ({
|
||||
}));
|
||||
|
||||
describe("NotificationProvider", () => {
|
||||
let mockSocket;
|
||||
let mockSocketContext;
|
||||
let mockSSEContext;
|
||||
let mockAuthContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create mock socket with event listener support
|
||||
mockSocket = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
};
|
||||
|
||||
mockSocketContext = {
|
||||
socket: mockSocket,
|
||||
mockSSEContext = {
|
||||
connected: true,
|
||||
notifications: [],
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||
clearNotification: jest.fn(),
|
||||
clearAllNotifications: jest.fn(),
|
||||
};
|
||||
|
||||
mockAuthContext = {
|
||||
@@ -52,9 +48,9 @@ describe("NotificationProvider", () => {
|
||||
const renderWithProviders = (children) => {
|
||||
return render(
|
||||
<AuthContext.Provider value={mockAuthContext}>
|
||||
<SocketContext.Provider value={mockSocketContext}>
|
||||
<SSEContext.Provider value={mockSSEContext}>
|
||||
<NotificationProvider>{children}</NotificationProvider>
|
||||
</SocketContext.Provider>
|
||||
</SSEContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -64,84 +60,17 @@ describe("NotificationProvider", () => {
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("subscribes to socket events when connected", () => {
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Verify socket event listeners were registered
|
||||
expect(mockSocket.on).toHaveBeenCalledWith("connect", expect.any(Function));
|
||||
expect(mockSocket.on).toHaveBeenCalledWith("disconnect", expect.any(Function));
|
||||
expect(mockSocket.on).toHaveBeenCalledWith("reconnect", expect.any(Function));
|
||||
expect(mockSocket.on).toHaveBeenCalledWith("reconnect_error", expect.any(Function));
|
||||
});
|
||||
|
||||
test("subscribes to custom events via context", () => {
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Verify custom event listeners were registered via context
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("achievementUnlocked", expect.any(Function));
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("newPost", expect.any(Function));
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("newComment", expect.any(Function));
|
||||
expect(mockSocketContext.on).toHaveBeenCalledWith("notification", expect.any(Function));
|
||||
});
|
||||
|
||||
test("shows success toast on connect", () => {
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Get the connect handler
|
||||
const connectHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === "connect"
|
||||
)?.[1];
|
||||
|
||||
// Trigger connect event
|
||||
if (connectHandler) {
|
||||
connectHandler();
|
||||
}
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"Connected to real-time updates",
|
||||
expect.objectContaining({ toastId: "socket-connected" })
|
||||
);
|
||||
});
|
||||
|
||||
test("shows error toast on server disconnect", () => {
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Get the disconnect handler
|
||||
const disconnectHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === "disconnect"
|
||||
)?.[1];
|
||||
|
||||
// Trigger disconnect event with server reason
|
||||
if (disconnectHandler) {
|
||||
disconnectHandler("io server disconnect");
|
||||
}
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Server disconnected. Attempting to reconnect...",
|
||||
expect.objectContaining({ toastId: "socket-disconnected" })
|
||||
);
|
||||
});
|
||||
|
||||
test("shows warning toast on transport error", () => {
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Get the disconnect handler
|
||||
const disconnectHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === "disconnect"
|
||||
)?.[1];
|
||||
|
||||
// Trigger disconnect event with transport error
|
||||
if (disconnectHandler) {
|
||||
disconnectHandler("transport error");
|
||||
}
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
"Connection lost. Reconnecting...",
|
||||
expect.objectContaining({ toastId: "socket-reconnecting" })
|
||||
);
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("achievementUnlocked", expect.any(Function));
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("newPost", expect.any(Function));
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("newComment", expect.any(Function));
|
||||
expect(mockSSEContext.on).toHaveBeenCalledWith("notification", expect.any(Function));
|
||||
});
|
||||
|
||||
test("cleans up event listeners on unmount", () => {
|
||||
@@ -149,34 +78,23 @@ describe("NotificationProvider", () => {
|
||||
|
||||
unmount();
|
||||
|
||||
// Verify socket event listeners were removed
|
||||
expect(mockSocket.off).toHaveBeenCalledWith("connect", expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith("disconnect", expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith("reconnect", expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith("reconnect_error", expect.any(Function));
|
||||
|
||||
// Verify custom event listeners were removed via context
|
||||
expect(mockSocketContext.off).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
||||
expect(mockSocketContext.off).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
||||
expect(mockSocketContext.off).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("eventUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("taskUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("streetUpdate", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("achievementUnlocked", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("newPost", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("newComment", expect.any(Function));
|
||||
expect(mockSSEContext.off).toHaveBeenCalledWith("notification", expect.any(Function));
|
||||
});
|
||||
|
||||
test("does not subscribe when socket is not connected", () => {
|
||||
mockSocketContext.connected = false;
|
||||
test("does not subscribe when not connected", () => {
|
||||
mockSSEContext.connected = false;
|
||||
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Socket event listeners should not be registered when not connected
|
||||
expect(mockSocket.on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not subscribe when socket is null", () => {
|
||||
mockSocketContext.socket = null;
|
||||
|
||||
renderWithProviders(<div>Test</div>);
|
||||
|
||||
// Socket event listeners should not be registered when socket is null
|
||||
expect(mockSocket.on).not.toHaveBeenCalled();
|
||||
// Event listeners should not be registered when not connected
|
||||
expect(mockSSEContext.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
import React from "react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import SSEProvider, { SSEContext, useSSE } from "../../context/SSEContext";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
import axios from "axios";
|
||||
|
||||
// Mock axios
|
||||
jest.mock("axios");
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.onopen = null;
|
||||
this.onerror = null;
|
||||
this.onmessage = null;
|
||||
this.readyState = 0;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 2;
|
||||
}
|
||||
|
||||
static instances = [];
|
||||
static reset() {
|
||||
MockEventSource.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
global.EventSource = MockEventSource;
|
||||
|
||||
describe("SSEContext", () => {
|
||||
let mockAuthContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MockEventSource.reset();
|
||||
|
||||
mockAuthContext = {
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
token: "mock-token",
|
||||
user: { id: "user123", name: "Test User" },
|
||||
},
|
||||
};
|
||||
|
||||
// Mock axios responses
|
||||
axios.post.mockResolvedValue({ data: { subscribed: [] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
const renderWithAuth = (children, authValue = mockAuthContext) => {
|
||||
return render(
|
||||
<AuthContext.Provider value={authValue}>
|
||||
<SSEProvider>{children}</SSEProvider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Connection Lifecycle", () => {
|
||||
it("renders children correctly", () => {
|
||||
renderWithAuth(<div>Test Content</div>);
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("connects to SSE stream when authenticated", async () => {
|
||||
renderWithAuth(<div>Test</div>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
expect(MockEventSource.instances[0].url).toContain("/api/sse/stream");
|
||||
expect(MockEventSource.instances[0].url).toContain("token=mock-token");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not connect when not authenticated", () => {
|
||||
const unauthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
user: null,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithAuth(<div>Test</div>, unauthContext);
|
||||
|
||||
expect(MockEventSource.instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it("sets connected state to true on open", async () => {
|
||||
const TestComponent = () => {
|
||||
const { connected } = useSSE();
|
||||
return <div>{connected ? "Connected" : "Disconnected"}</div>;
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Trigger onopen
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onopen();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles connection errors", async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
renderWithAuth(<div>Test</div>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Trigger onerror
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onerror(new Error("Connection error"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"SSE: Connection error:",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("disconnects when user logs out", async () => {
|
||||
const { rerender } = renderWithAuth(<div>Test</div>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
const closeSpy = jest.spyOn(eventSource, "close");
|
||||
|
||||
// Update auth to unauthenticated
|
||||
const unauthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
user: null,
|
||||
},
|
||||
};
|
||||
|
||||
rerender(
|
||||
<AuthContext.Provider value={unauthContext}>
|
||||
<SSEProvider>
|
||||
<div>Test</div>
|
||||
</SSEProvider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes connection on unmount", async () => {
|
||||
const { unmount } = renderWithAuth(<div>Test</div>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
eventSource.close = jest.fn(); // Replace close method with spy
|
||||
|
||||
unmount();
|
||||
|
||||
// Wait for cleanup to complete
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
expect(eventSource.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event Handler Registration", () => {
|
||||
it("registers event handlers with on()", async () => {
|
||||
const TestComponent = () => {
|
||||
const { on } = useSSE();
|
||||
React.useEffect(() => {
|
||||
const handler = jest.fn();
|
||||
on("testEvent", handler);
|
||||
}, [on]);
|
||||
return <div>Test</div>;
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("unregisters event handlers with off()", async () => {
|
||||
const TestComponent = () => {
|
||||
const { on, off } = useSSE();
|
||||
React.useEffect(() => {
|
||||
const handler = jest.fn();
|
||||
on("testEvent", handler);
|
||||
return () => {
|
||||
off("testEvent", handler);
|
||||
};
|
||||
}, [on, off]);
|
||||
return <div>Test</div>;
|
||||
};
|
||||
|
||||
const { unmount } = renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("calls registered handlers when event is received", async () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const { on } = useSSE();
|
||||
React.useEffect(() => {
|
||||
on("testEvent", handler);
|
||||
}, [on]);
|
||||
return <div>Test</div>;
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Simulate receiving a message
|
||||
const eventData = {
|
||||
type: "testEvent",
|
||||
payload: { message: "Test message" },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify(eventData),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handler).toHaveBeenCalledWith({ message: "Test message" });
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple handlers for the same event type", async () => {
|
||||
const handler1 = jest.fn();
|
||||
const handler2 = jest.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const { on } = useSSE();
|
||||
React.useEffect(() => {
|
||||
on("testEvent", handler1);
|
||||
on("testEvent", handler2);
|
||||
}, [on]);
|
||||
return <div>Test</div>;
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Simulate receiving a message
|
||||
const eventData = {
|
||||
type: "testEvent",
|
||||
payload: { message: "Test message" },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify(eventData),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handler1).toHaveBeenCalledWith({ message: "Test message" });
|
||||
expect(handler2).toHaveBeenCalledWith({ message: "Test message" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Topic Subscription API", () => {
|
||||
it("subscribes to topics via API call", async () => {
|
||||
const TestComponent = () => {
|
||||
const { subscribe } = useSSE();
|
||||
return (
|
||||
<button onClick={() => subscribe(["events", "tasks"])}>
|
||||
Subscribe
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
const subscribeButton = screen.getByText("Subscribe");
|
||||
act(() => {
|
||||
subscribeButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.post).toHaveBeenCalledWith("/api/sse/subscribe", {
|
||||
topics: ["events", "tasks"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from topics via API call", async () => {
|
||||
const TestComponent = () => {
|
||||
const { unsubscribe } = useSSE();
|
||||
return (
|
||||
<button onClick={() => unsubscribe(["events", "tasks"])}>
|
||||
Unsubscribe
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
const unsubscribeButton = screen.getByText("Unsubscribe");
|
||||
act(() => {
|
||||
unsubscribeButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.post).toHaveBeenCalledWith("/api/sse/unsubscribe", {
|
||||
topics: ["events", "tasks"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not subscribe when not authenticated", async () => {
|
||||
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const unauthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
user: null,
|
||||
},
|
||||
};
|
||||
|
||||
const TestComponent = () => {
|
||||
const { subscribe } = useSSE();
|
||||
return (
|
||||
<button onClick={() => subscribe(["events"])}>Subscribe</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />, unauthContext);
|
||||
|
||||
const subscribeButton = screen.getByText("Subscribe");
|
||||
act(() => {
|
||||
subscribeButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"SSE: Cannot subscribe - not authenticated"
|
||||
);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles subscription errors gracefully", async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
axios.post.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const TestComponent = () => {
|
||||
const { subscribe } = useSSE();
|
||||
return (
|
||||
<button onClick={() => subscribe(["events"])}>Subscribe</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
const subscribeButton = screen.getByText("Subscribe");
|
||||
act(() => {
|
||||
subscribeButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"SSE: Error subscribing to topics:",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Notification State Management", () => {
|
||||
it("adds notifications to state when received", async () => {
|
||||
const TestComponent = () => {
|
||||
const { notifications } = useSSE();
|
||||
return (
|
||||
<div>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id}>{n.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Simulate receiving a notification
|
||||
const eventData = {
|
||||
type: "notification",
|
||||
payload: { message: "New notification" },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify(eventData),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New notification")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a specific notification", async () => {
|
||||
const TestComponent = () => {
|
||||
const { notifications, clearNotification } = useSSE();
|
||||
return (
|
||||
<div>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id}>
|
||||
{n.message}
|
||||
<button onClick={() => clearNotification(n.id)}>Clear</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Add a notification
|
||||
const eventData = {
|
||||
type: "notification",
|
||||
payload: { message: "Test notification" },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify(eventData),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test notification")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear the notification
|
||||
const clearButton = screen.getByText("Clear");
|
||||
act(() => {
|
||||
clearButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test notification")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears all notifications", async () => {
|
||||
const TestComponent = () => {
|
||||
const { notifications, clearAllNotifications } = useSSE();
|
||||
return (
|
||||
<div>
|
||||
<button onClick={clearAllNotifications}>Clear All</button>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id}>{n.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Add multiple notifications
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify({
|
||||
type: "notification",
|
||||
payload: { message: "Notification 1" },
|
||||
}),
|
||||
});
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify({
|
||||
type: "notification",
|
||||
payload: { message: "Notification 2" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Notification 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notification 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear all notifications
|
||||
const clearAllButton = screen.getByText("Clear All");
|
||||
act(() => {
|
||||
clearAllButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Notification 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Notification 2")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSSE Hook", () => {
|
||||
it("throws error when used outside SSEProvider", () => {
|
||||
const TestComponent = () => {
|
||||
useSSE();
|
||||
return <div>Test</div>;
|
||||
};
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow("useSSE must be used within an SSEProvider");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns SSE context value when used correctly", () => {
|
||||
const TestComponent = () => {
|
||||
const context = useSSE();
|
||||
return (
|
||||
<div>
|
||||
{context.connected ? "Connected" : "Disconnected"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("handles malformed JSON messages gracefully", async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
renderWithAuth(<div>Test</div>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Send malformed JSON
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: "invalid json",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"SSE: Error parsing message:",
|
||||
expect.any(Error),
|
||||
"invalid json"
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles errors in event handlers gracefully", async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
const throwingHandler = jest.fn(() => {
|
||||
throw new Error("Handler error");
|
||||
});
|
||||
|
||||
const TestComponent = () => {
|
||||
const { on } = useSSE();
|
||||
React.useEffect(() => {
|
||||
on("testEvent", throwingHandler);
|
||||
}, [on]);
|
||||
return <div>Test</div>;
|
||||
};
|
||||
|
||||
renderWithAuth(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Trigger the handler
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage({
|
||||
data: JSON.stringify({
|
||||
type: "testEvent",
|
||||
payload: { message: "Test" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"SSE: Error in event handler for testEvent:",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,12 @@ import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
import { SSEContext } from "../context/SSEContext";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Events = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off, joinEvent, leaveEvent } = useContext(SocketContext);
|
||||
const { connected, on, off, subscribe, unsubscribe } = useContext(SSEContext);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -32,9 +32,20 @@ const Events = () => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// Subscribe to global events topic on mount
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
|
||||
subscribe(["events"]);
|
||||
|
||||
return () => {
|
||||
unsubscribe(["events"]);
|
||||
};
|
||||
}, [connected, subscribe, unsubscribe]);
|
||||
|
||||
// Handle real-time event updates
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
if (!connected) return;
|
||||
|
||||
const handleEventUpdate = (data) => {
|
||||
console.log("Received event update:", data);
|
||||
@@ -72,26 +83,33 @@ const Events = () => {
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("eventUpdate", handleEventUpdate);
|
||||
|
||||
// Leave all joined event rooms
|
||||
joinedEvents.forEach((eventId) => {
|
||||
leaveEvent(eventId);
|
||||
});
|
||||
};
|
||||
}, [socket, connected, on, off, joinedEvents, leaveEvent]);
|
||||
}, [connected, on, off]);
|
||||
|
||||
// Join event room when viewing events
|
||||
// Subscribe to individual event topics when viewing events
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
if (!connected) return;
|
||||
|
||||
// Join each event room for real-time updates
|
||||
events.forEach((event) => {
|
||||
if (!joinedEvents.has(event._id)) {
|
||||
joinEvent(event._id);
|
||||
setJoinedEvents((prev) => new Set([...prev, event._id]));
|
||||
// Subscribe to each event topic for real-time updates
|
||||
const newEventIds = events
|
||||
.map((event) => event._id)
|
||||
.filter((id) => !joinedEvents.has(id));
|
||||
|
||||
if (newEventIds.length > 0) {
|
||||
subscribe(newEventIds.map((id) => `event_${id}`));
|
||||
setJoinedEvents((prev) => new Set([...prev, ...newEventIds]));
|
||||
}
|
||||
|
||||
// Cleanup: unsubscribe from event topics that are no longer in the list
|
||||
return () => {
|
||||
const eventIdsToUnsubscribe = Array.from(joinedEvents).filter(
|
||||
(id) => !events.some((event) => event._id === id)
|
||||
);
|
||||
if (eventIdsToUnsubscribe.length > 0) {
|
||||
unsubscribe(eventIdsToUnsubscribe.map((id) => `event_${id}`));
|
||||
}
|
||||
});
|
||||
}, [events, socket, connected, joinEvent, joinedEvents]);
|
||||
};
|
||||
}, [events, connected, subscribe, unsubscribe, joinedEvents]);
|
||||
|
||||
const rsvp = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
|
||||
@@ -3,15 +3,15 @@ import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
import { SSEContext } from "../context/SSEContext";
|
||||
|
||||
/**
|
||||
* SocialFeed component displays community posts and allows creating new posts
|
||||
* Includes real-time updates via Socket.IO
|
||||
* Includes real-time updates via SSE
|
||||
*/
|
||||
const SocialFeed = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const { connected, on, off, subscribe, unsubscribe } = useContext(SSEContext);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,9 +43,20 @@ const SocialFeed = () => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
// Handle real-time post updates via Socket.IO
|
||||
// Subscribe to posts topic on mount
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
if (!connected) return;
|
||||
|
||||
subscribe(["posts"]);
|
||||
|
||||
return () => {
|
||||
unsubscribe(["posts"]);
|
||||
};
|
||||
}, [connected, subscribe, unsubscribe]);
|
||||
|
||||
// Handle real-time post updates via SSE
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
|
||||
const handleNewPost = (data) => {
|
||||
console.log("Received new post:", data);
|
||||
@@ -101,7 +112,7 @@ const SocialFeed = () => {
|
||||
off("postUpdate", handlePostUpdate);
|
||||
off("newComment", handleNewComment);
|
||||
};
|
||||
}, [socket, connected, on, off]);
|
||||
}, [connected, on, off]);
|
||||
|
||||
// Like a post
|
||||
const likePost = async (id) => {
|
||||
|
||||
@@ -3,15 +3,15 @@ import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
import { SSEContext } from "../context/SSEContext";
|
||||
|
||||
/**
|
||||
* TaskList component displays maintenance tasks and allows task completion
|
||||
* Includes real-time updates via Socket.IO
|
||||
* Includes real-time updates via SSE
|
||||
*/
|
||||
const TaskList = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const { connected, on, off, subscribe, unsubscribe } = useContext(SSEContext);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -41,9 +41,20 @@ const TaskList = () => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// Handle real-time task updates via Socket.IO
|
||||
// Subscribe to tasks topic on mount
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
if (!connected) return;
|
||||
|
||||
subscribe(["tasks"]);
|
||||
|
||||
return () => {
|
||||
unsubscribe(["tasks"]);
|
||||
};
|
||||
}, [connected, subscribe, unsubscribe]);
|
||||
|
||||
// Handle real-time task updates via SSE
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
|
||||
const handleTaskUpdate = (data) => {
|
||||
console.log("Received task update:", data);
|
||||
@@ -82,7 +93,7 @@ const TaskList = () => {
|
||||
return () => {
|
||||
off("taskUpdate", handleTaskUpdate);
|
||||
};
|
||||
}, [socket, connected, on, off]);
|
||||
}, [connected, on, off]);
|
||||
|
||||
// Complete a task
|
||||
const completeTask = async (id) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
import { SocketContext } from '../../context/SocketContext';
|
||||
import { SSEContext } from '../../context/SSEContext';
|
||||
import Events from '../Events';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -13,13 +13,15 @@ const mockAuthContext = {
|
||||
logout: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSocketContext = {
|
||||
socket: null,
|
||||
const mockSSEContext = {
|
||||
connected: true,
|
||||
notifications: [],
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
joinEvent: jest.fn(),
|
||||
leaveEvent: jest.fn(),
|
||||
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||
clearNotification: jest.fn(),
|
||||
clearAllNotifications: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('axios');
|
||||
@@ -83,9 +85,9 @@ describe('Events Component', () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={mockAuthContext}>
|
||||
<SocketContext.Provider value={mockSocketContext}>
|
||||
<SSEContext.Provider value={mockSSEContext}>
|
||||
<Events />
|
||||
</SocketContext.Provider>
|
||||
</SSEContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
@@ -296,19 +298,19 @@ describe('Events Component', () => {
|
||||
});
|
||||
|
||||
it('handles real-time updates', async () => {
|
||||
const { on } = mockSocketContext;
|
||||
const { on } = mockSSEContext;
|
||||
|
||||
renderEvents();
|
||||
|
||||
await waitFor(() => {
|
||||
// Simulate receiving a new event via socket
|
||||
const socketCallback = on.mock.calls[0][1];
|
||||
// Simulate receiving a new event via SSE
|
||||
const sseCallback = on.mock.calls[0][1];
|
||||
const newEventData = {
|
||||
type: 'new_event',
|
||||
data: { ...mockEvents[0], _id: 'event5' }
|
||||
event: { ...mockEvents[0], _id: 'event5' }
|
||||
};
|
||||
|
||||
socketCallback(newEventData);
|
||||
sseCallback(newEventData);
|
||||
|
||||
// Verify new event appears in the list
|
||||
expect(screen.getByText('Community Cleanup Day')).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
import { SocketContext } from '../../context/SocketContext';
|
||||
import { SSEContext } from '../../context/SSEContext';
|
||||
import SocialFeed from '../SocialFeed';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -13,11 +13,15 @@ const mockAuthContext = {
|
||||
logout: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSocketContext = {
|
||||
socket: null,
|
||||
const mockSSEContext = {
|
||||
connected: true,
|
||||
notifications: [],
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||
clearNotification: jest.fn(),
|
||||
clearAllNotifications: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock axios
|
||||
@@ -75,9 +79,9 @@ describe('SocialFeed Component', () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={mockAuthContext}>
|
||||
<SocketContext.Provider value={mockSocketContext}>
|
||||
<SSEContext.Provider value={mockSSEContext}>
|
||||
<SocialFeed />
|
||||
</SocketContext.Provider>
|
||||
</SSEContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
@@ -265,19 +269,19 @@ describe('SocialFeed Component', () => {
|
||||
});
|
||||
|
||||
it('handles real-time updates', async () => {
|
||||
const { on } = mockSocketContext;
|
||||
const { on } = mockSSEContext;
|
||||
|
||||
renderSocialFeed();
|
||||
|
||||
await waitFor(() => {
|
||||
// Simulate receiving a new post via socket
|
||||
const socketCallback = on.mock.calls[0][1];
|
||||
// Simulate receiving a new post via SSE
|
||||
const sseCallback = on.mock.calls[0][1];
|
||||
const newPostData = {
|
||||
type: 'new_post',
|
||||
data: { ...mockPosts[0], _id: 'post3', content: 'New real-time post!' }
|
||||
};
|
||||
|
||||
socketCallback(newPostData);
|
||||
sseCallback(newPostData);
|
||||
|
||||
// Verify the new post appears in the feed
|
||||
expect(screen.getByText('New real-time post!')).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
import { SocketContext } from '../../context/SocketContext';
|
||||
import { SSEContext } from '../../context/SSEContext';
|
||||
import TaskList from '../TaskList';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -13,11 +13,15 @@ const mockAuthContext = {
|
||||
logout: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSocketContext = {
|
||||
socket: null,
|
||||
const mockSSEContext = {
|
||||
connected: true,
|
||||
notifications: [],
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
subscribe: jest.fn().mockResolvedValue({ subscribed: [] }),
|
||||
unsubscribe: jest.fn().mockResolvedValue({ unsubscribed: [] }),
|
||||
clearNotification: jest.fn(),
|
||||
clearAllNotifications: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock axios
|
||||
@@ -61,9 +65,9 @@ describe('TaskList Component', () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={mockAuthContext}>
|
||||
<SocketContext.Provider value={mockSocketContext}>
|
||||
<SSEContext.Provider value={mockSSEContext}>
|
||||
<TaskList />
|
||||
</SocketContext.Provider>
|
||||
</SSEContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
@@ -232,19 +236,19 @@ describe('TaskList Component', () => {
|
||||
});
|
||||
|
||||
it('handles real-time updates', async () => {
|
||||
const { on } = mockSocketContext;
|
||||
const { on } = mockSSEContext;
|
||||
|
||||
renderTaskList();
|
||||
|
||||
await waitFor(() => {
|
||||
// Simulate receiving a task update via socket
|
||||
const socketCallback = on.mock.calls[0][1];
|
||||
// Simulate receiving a task update via SSE
|
||||
const sseCallback = on.mock.calls[0][1];
|
||||
const taskUpdateData = {
|
||||
type: 'task_update',
|
||||
data: { ...mockTasks[0], status: 'completed' }
|
||||
};
|
||||
|
||||
socketCallback(taskUpdateData);
|
||||
sseCallback(taskUpdateData);
|
||||
|
||||
// Verify the task list updates with new data
|
||||
expect(screen.getByText('Clean up the street')).toBeInTheDocument();
|
||||
|
||||
@@ -1,50 +1,31 @@
|
||||
import React, { useEffect, useContext } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { SocketContext } from "./SocketContext";
|
||||
import { SSEContext } from "./SSEContext";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
/**
|
||||
* NotificationProvider integrates Socket.IO events with toast notifications
|
||||
* NotificationProvider integrates SSE events with toast notifications
|
||||
* Automatically displays toast notifications for various real-time events
|
||||
*/
|
||||
const NotificationProvider = ({ children }) => {
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const { connected, on, off } = useContext(SSEContext);
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
// Watch connection state for connection status toasts
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
// Connection status notifications
|
||||
const handleConnect = () => {
|
||||
if (connected) {
|
||||
toast.success("Connected to real-time updates", {
|
||||
toastId: "socket-connected", // Prevent duplicate toasts
|
||||
toastId: "sse-connected", // Prevent duplicate toasts
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisconnect = (reason) => {
|
||||
if (reason === "io server disconnect") {
|
||||
toast.error("Server disconnected. Attempting to reconnect...", {
|
||||
toastId: "socket-disconnected",
|
||||
});
|
||||
} else if (reason === "transport close" || reason === "transport error") {
|
||||
toast.warning("Connection lost. Reconnecting...", {
|
||||
toastId: "socket-reconnecting",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
toast.success("Reconnected to server", {
|
||||
toastId: "socket-reconnected",
|
||||
} else {
|
||||
toast.warning("Connection lost. Reconnecting...", {
|
||||
toastId: "sse-reconnecting",
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
const handleReconnectError = () => {
|
||||
toast.error("Failed to reconnect. Please refresh the page.", {
|
||||
toastId: "socket-reconnect-error",
|
||||
autoClose: false, // Keep visible until user dismisses
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
|
||||
// Event-related notifications
|
||||
const handleEventUpdate = (data) => {
|
||||
@@ -169,12 +150,7 @@ const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to socket events
|
||||
socket.on("connect", handleConnect);
|
||||
socket.on("disconnect", handleDisconnect);
|
||||
socket.on("reconnect", handleReconnect);
|
||||
socket.on("reconnect_error", handleReconnectError);
|
||||
|
||||
// Subscribe to SSE events
|
||||
on("eventUpdate", handleEventUpdate);
|
||||
on("taskUpdate", handleTaskUpdate);
|
||||
on("streetUpdate", handleStreetUpdate);
|
||||
@@ -186,11 +162,6 @@ const NotificationProvider = ({ children }) => {
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.off("connect", handleConnect);
|
||||
socket.off("disconnect", handleDisconnect);
|
||||
socket.off("reconnect", handleReconnect);
|
||||
socket.off("reconnect_error", handleReconnectError);
|
||||
|
||||
off("eventUpdate", handleEventUpdate);
|
||||
off("taskUpdate", handleTaskUpdate);
|
||||
off("streetUpdate", handleStreetUpdate);
|
||||
@@ -200,7 +171,7 @@ const NotificationProvider = ({ children }) => {
|
||||
off("newComment", handleNewComment);
|
||||
off("notification", handleNotification);
|
||||
};
|
||||
}, [socket, connected, on, off, auth.user]);
|
||||
}, [connected, on, off, auth.user]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||
import axios from "axios";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
export const SSEContext = createContext();
|
||||
|
||||
/**
|
||||
* SSEProvider manages Server-Sent Events connections and real-time event handling
|
||||
* Automatically reconnects on disconnection and provides event subscription methods
|
||||
*/
|
||||
const SSEProvider = ({ children }) => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [eventSource, setEventSource] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const eventHandlersRef = useRef(new Map());
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAY = 1000;
|
||||
|
||||
// Clean up reconnect timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Connect to SSE stream
|
||||
const connectSSE = useCallback(() => {
|
||||
if (!auth.isAuthenticated || !auth.token) {
|
||||
console.log("SSE: Not authenticated, skipping connection");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("SSE: Connecting to event stream");
|
||||
const url = `/api/sse/stream?token=${encodeURIComponent(auth.token)}`;
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
console.log("SSE: Connection established");
|
||||
setConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
es.onerror = (error) => {
|
||||
console.error("SSE: Connection error:", error);
|
||||
setConnected(false);
|
||||
es.close();
|
||||
setEventSource(null);
|
||||
|
||||
// Attempt reconnection with exponential backoff
|
||||
if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||
console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
connectSSE();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
console.error("SSE: Max reconnection attempts reached");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle incoming messages
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("SSE: Received message:", data);
|
||||
|
||||
const { type, payload } = data;
|
||||
|
||||
// Add to notifications array
|
||||
if (type === "notification" || !type) {
|
||||
setNotifications((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now(),
|
||||
timestamp: new Date(),
|
||||
...payload,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Call registered event handlers
|
||||
if (type) {
|
||||
const handlers = eventHandlersRef.current.get(type);
|
||||
if (handlers && handlers.size > 0) {
|
||||
handlers.forEach((callback) => {
|
||||
try {
|
||||
callback(payload || data);
|
||||
} catch (error) {
|
||||
console.error(`SSE: Error in event handler for ${type}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SSE: Error parsing message:", error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
setEventSource(es);
|
||||
}, [auth.isAuthenticated, auth.token]);
|
||||
|
||||
// Connect when user is authenticated
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated && auth.token) {
|
||||
connectSSE();
|
||||
} else {
|
||||
// Disconnect when user logs out
|
||||
if (eventSource) {
|
||||
console.log("SSE: Disconnecting due to logout");
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
setConnected(false);
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [auth.isAuthenticated, auth.token, connectSSE]);
|
||||
|
||||
// Subscribe to a specific event type
|
||||
const on = useCallback((eventType, callback) => {
|
||||
if (!eventHandlersRef.current.has(eventType)) {
|
||||
eventHandlersRef.current.set(eventType, new Set());
|
||||
}
|
||||
eventHandlersRef.current.get(eventType).add(callback);
|
||||
console.log(`SSE: Registered handler for event type: ${eventType}`);
|
||||
}, []);
|
||||
|
||||
// Unsubscribe from a specific event type
|
||||
const off = useCallback((eventType, callback) => {
|
||||
const handlers = eventHandlersRef.current.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.delete(callback);
|
||||
if (handlers.size === 0) {
|
||||
eventHandlersRef.current.delete(eventType);
|
||||
}
|
||||
console.log(`SSE: Unregistered handler for event type: ${eventType}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to specific topics via backend
|
||||
const subscribe = useCallback(
|
||||
async (topics) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
console.warn("SSE: Cannot subscribe - not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post("/api/sse/subscribe", { topics });
|
||||
console.log("SSE: Subscribed to topics:", topics);
|
||||
} catch (error) {
|
||||
console.error("SSE: Error subscribing to topics:", error);
|
||||
}
|
||||
},
|
||||
[auth.isAuthenticated]
|
||||
);
|
||||
|
||||
// Unsubscribe from specific topics via backend
|
||||
const unsubscribe = useCallback(
|
||||
async (topics) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
console.warn("SSE: Cannot unsubscribe - not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post("/api/sse/unsubscribe", { topics });
|
||||
console.log("SSE: Unsubscribed from topics:", topics);
|
||||
} catch (error) {
|
||||
console.error("SSE: Error unsubscribing from topics:", error);
|
||||
}
|
||||
},
|
||||
[auth.isAuthenticated]
|
||||
);
|
||||
|
||||
// Clear a notification
|
||||
const clearNotification = useCallback((notificationId) => {
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notification) => notification.id !== notificationId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clear all notifications
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
eventSource,
|
||||
connected,
|
||||
notifications,
|
||||
eventHandlers: eventHandlersRef.current,
|
||||
on,
|
||||
off,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
clearNotification,
|
||||
clearAllNotifications,
|
||||
};
|
||||
|
||||
return (
|
||||
<SSEContext.Provider value={value}>{children}</SSEContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSEProvider;
|
||||
|
||||
/**
|
||||
* Custom hook to use SSE context
|
||||
* @returns {Object} SSE context value
|
||||
*/
|
||||
export const useSSE = () => {
|
||||
const context = useContext(SSEContext);
|
||||
if (!context) {
|
||||
throw new Error("useSSE must be used within an SSEProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
export const SocketContext = createContext();
|
||||
|
||||
/**
|
||||
* SocketProvider manages WebSocket connections and real-time event handling
|
||||
* Automatically reconnects on disconnection and provides event subscription methods
|
||||
*/
|
||||
const SocketProvider = ({ children }) => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize socket connection
|
||||
const socketInstance = io("http://localhost:5000", {
|
||||
autoConnect: false,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
socketInstance.on("connect", () => {
|
||||
console.log("Socket.IO connected:", socketInstance.id);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("disconnect", (reason) => {
|
||||
console.log("Socket.IO disconnected:", reason);
|
||||
setConnected(false);
|
||||
|
||||
// Automatically reconnect if disconnection was unexpected
|
||||
if (reason === "io server disconnect") {
|
||||
// Server initiated disconnect, reconnect manually
|
||||
socketInstance.connect();
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on("connect_error", (error) => {
|
||||
console.error("Socket.IO connection error:", error);
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect", (attemptNumber) => {
|
||||
console.log("Socket.IO reconnected after", attemptNumber, "attempts");
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_attempt", (attemptNumber) => {
|
||||
console.log("Socket.IO reconnection attempt", attemptNumber);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_error", (error) => {
|
||||
console.error("Socket.IO reconnection error:", error);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_failed", () => {
|
||||
console.error("Socket.IO reconnection failed");
|
||||
});
|
||||
|
||||
// Generic notification handler
|
||||
socketInstance.on("notification", (data) => {
|
||||
console.log("Received notification:", data);
|
||||
setNotifications((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now(),
|
||||
timestamp: new Date(),
|
||||
...data,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Connect socket if user is authenticated
|
||||
if (auth.isAuthenticated) {
|
||||
socketInstance.connect();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
socketInstance.removeAllListeners();
|
||||
};
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Join a specific event room
|
||||
const joinEvent = useCallback(
|
||||
(eventId) => {
|
||||
if (socket && connected) {
|
||||
console.log("Joining event room:", eventId);
|
||||
socket.emit("joinEvent", eventId);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Leave a specific event room
|
||||
const leaveEvent = useCallback(
|
||||
(eventId) => {
|
||||
if (socket && connected) {
|
||||
console.log("Leaving event room:", eventId);
|
||||
socket.emit("leaveEvent", eventId);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Subscribe to a specific event
|
||||
const on = useCallback(
|
||||
(event, callback) => {
|
||||
if (socket) {
|
||||
socket.on(event, callback);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
// Unsubscribe from a specific event
|
||||
const off = useCallback(
|
||||
(event, callback) => {
|
||||
if (socket) {
|
||||
socket.off(event, callback);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
// Emit an event
|
||||
const emit = useCallback(
|
||||
(event, data) => {
|
||||
if (socket && connected) {
|
||||
socket.emit(event, data);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Clear a notification
|
||||
const clearNotification = useCallback((notificationId) => {
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notification) => notification.id !== notificationId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clear all notifications
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
socket,
|
||||
connected,
|
||||
notifications,
|
||||
joinEvent,
|
||||
leaveEvent,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
clearNotification,
|
||||
clearAllNotifications,
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocketProvider;
|
||||
|
||||
/**
|
||||
* Custom hook to use socket context
|
||||
* @returns {Object} Socket context value
|
||||
*/
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user