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