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
@@ -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();
});
});