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:
William Valentin
2025-12-05 22:49:22 -08:00
parent b5ee7571c9
commit bb9c8ec1c3
571 changed files with 156739 additions and 1350 deletions
+3 -3
View File
@@ -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();
});
});
});
+36 -18
View File
@@ -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) {
+17 -6
View File
@@ -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) => {
+17 -6
View File
@@ -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();
+15 -44
View File
@@ -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}</>;
};
+240
View File
@@ -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;
};
-188
View File
@@ -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;
};