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
+29 -140
View File
@@ -23,7 +23,6 @@
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"recharts": "^3.3.0",
"socket.io-client": "^4.8.1",
"web-vitals": "^2.1.4"
},
"devDependencies": {
@@ -89,6 +88,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -729,6 +729,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.26.0.tgz",
"integrity": "sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
},
@@ -1593,6 +1594,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz",
"integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-module-imports": "^7.25.9",
@@ -3432,12 +3434,6 @@
"@sinonjs/commons": "^1.7.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -3697,6 +3693,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4264,6 +4261,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@@ -4317,6 +4315,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -4686,6 +4685,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4772,6 +4772,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5685,6 +5686,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -7384,66 +7386,6 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -7736,6 +7678,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10544,6 +10487,7 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@@ -11691,7 +11635,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/leven": {
"version": "3.1.0",
@@ -12985,6 +12930,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -14172,6 +14118,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -14537,6 +14484,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14674,6 +14622,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -14691,7 +14640,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@@ -14712,6 +14662,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -14735,6 +14686,7 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14964,7 +14916,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -15318,6 +15271,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -15560,6 +15514,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -15948,68 +15903,6 @@
"node": ">=8"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -17269,6 +17162,7 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -17739,6 +17633,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -17808,6 +17703,7 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@@ -18220,6 +18116,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -18540,14 +18437,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
-1
View File
@@ -18,7 +18,6 @@
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"recharts": "^3.3.0",
"socket.io-client": "^4.8.1",
"web-vitals": "^2.1.4"
},
"proxy": "http://localhost:5000",
+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;
};