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:
@@ -0,0 +1,226 @@
|
||||
const request = require("supertest");
|
||||
const { app, server } = require("../../server");
|
||||
const User = require("../../models/User");
|
||||
const sseService = require("../../services/sseService");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const couchdbService = require("../../services/couchdbService");
|
||||
|
||||
describe("SSE Routes", () => {
|
||||
let token;
|
||||
let userId;
|
||||
|
||||
beforeAll(async () => {
|
||||
await couchdbService.initialize();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique email to avoid conflicts
|
||||
const timestamp = Date.now();
|
||||
const user = await User.create({
|
||||
name: "SSE Test User",
|
||||
username: `sseuser${timestamp}`,
|
||||
email: `sse${timestamp}@test.com`,
|
||||
password: "Password123!",
|
||||
});
|
||||
userId = user._id;
|
||||
|
||||
// Generate token
|
||||
const payload = { user: { id: user._id } };
|
||||
token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
|
||||
// Clear SSE service state
|
||||
sseService.clients.clear();
|
||||
sseService.topics.clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await couchdbService.shutdown();
|
||||
server.close();
|
||||
});
|
||||
|
||||
describe("POST /api/sse/subscribe", () => {
|
||||
test("should require authentication", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/sse/subscribe")
|
||||
.send({ topics: ["test"] });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test("should subscribe to topics with valid token", async () => {
|
||||
// First, add client to SSE service
|
||||
const mockRes = { write: jest.fn() };
|
||||
sseService.addClient(userId, mockRes);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/sse/subscribe")
|
||||
.set("x-auth-token", token)
|
||||
.send({ topics: ["events", "posts"] });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.topics).toEqual(["events", "posts"]);
|
||||
|
||||
// Verify subscription in service
|
||||
const stats = sseService.getStats();
|
||||
expect(stats.topics).toHaveProperty("events");
|
||||
expect(stats.topics).toHaveProperty("posts");
|
||||
});
|
||||
|
||||
test("should fail if user not connected to SSE stream", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/sse/subscribe")
|
||||
.set("x-auth-token", token)
|
||||
.send({ topics: ["events"] });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test("should validate topics array", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/sse/subscribe")
|
||||
.set("x-auth-token", token)
|
||||
.send({ topics: "not-an-array" });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/sse/unsubscribe", () => {
|
||||
test("should require authentication", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/sse/unsubscribe")
|
||||
.send({ topics: ["test"] });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test("should unsubscribe from topics", async () => {
|
||||
// Setup: Add client and subscribe
|
||||
const mockRes = { write: jest.fn() };
|
||||
sseService.addClient(userId, mockRes);
|
||||
sseService.subscribe(userId, ["events", "posts"]);
|
||||
|
||||
// Unsubscribe from one topic
|
||||
const res = await request(app)
|
||||
.post("/api/sse/unsubscribe")
|
||||
.set("x-auth-token", token)
|
||||
.send({ topics: ["events"] });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify unsubscription
|
||||
const stats = sseService.getStats();
|
||||
expect(stats.topics).not.toHaveProperty("events");
|
||||
expect(stats.topics).toHaveProperty("posts");
|
||||
});
|
||||
|
||||
test("should validate topics array", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/sse/unsubscribe")
|
||||
.set("x-auth-token", token)
|
||||
.send({ topics: null });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE Service", () => {
|
||||
test("should add and remove clients", () => {
|
||||
const mockRes = { write: jest.fn() };
|
||||
|
||||
sseService.addClient(userId, mockRes);
|
||||
let stats = sseService.getStats();
|
||||
expect(stats.totalClients).toBe(1);
|
||||
|
||||
sseService.removeClient(userId);
|
||||
stats = sseService.getStats();
|
||||
expect(stats.totalClients).toBe(0);
|
||||
});
|
||||
|
||||
test("should broadcast to all clients", () => {
|
||||
const mockRes1 = { write: jest.fn() };
|
||||
const mockRes2 = { write: jest.fn() };
|
||||
|
||||
sseService.addClient("user1", mockRes1);
|
||||
sseService.addClient("user2", mockRes2);
|
||||
|
||||
sseService.broadcast("testEvent", { message: "Hello" });
|
||||
|
||||
expect(mockRes1.write).toHaveBeenCalledWith(
|
||||
'event: testEvent\ndata: {"message":"Hello"}\n\n'
|
||||
);
|
||||
expect(mockRes2.write).toHaveBeenCalledWith(
|
||||
'event: testEvent\ndata: {"message":"Hello"}\n\n'
|
||||
);
|
||||
});
|
||||
|
||||
test("should broadcast to topic subscribers only", () => {
|
||||
const mockRes1 = { write: jest.fn() };
|
||||
const mockRes2 = { write: jest.fn() };
|
||||
|
||||
sseService.addClient("user1", mockRes1);
|
||||
sseService.addClient("user2", mockRes2);
|
||||
sseService.subscribe("user1", ["events"]);
|
||||
|
||||
sseService.broadcastToTopic("events", "eventUpdate", { id: 1 });
|
||||
|
||||
expect(mockRes1.write).toHaveBeenCalled();
|
||||
expect(mockRes2.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send to specific user", () => {
|
||||
const mockRes = { write: jest.fn() };
|
||||
|
||||
sseService.addClient(userId, mockRes);
|
||||
const success = sseService.sendToUser(userId, "notification", { text: "Test" });
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(
|
||||
'event: notification\ndata: {"text":"Test"}\n\n'
|
||||
);
|
||||
});
|
||||
|
||||
test("should return false when sending to non-existent user", () => {
|
||||
const success = sseService.sendToUser("nonexistent", "test", {});
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
test("should get accurate stats", () => {
|
||||
const mockRes1 = { write: jest.fn() };
|
||||
const mockRes2 = { write: jest.fn() };
|
||||
|
||||
sseService.addClient("user1", mockRes1);
|
||||
sseService.addClient("user2", mockRes2);
|
||||
sseService.subscribe("user1", ["events", "posts"]);
|
||||
sseService.subscribe("user2", ["events"]);
|
||||
|
||||
const stats = sseService.getStats();
|
||||
|
||||
expect(stats.totalClients).toBe(2);
|
||||
expect(stats.totalTopics).toBe(2);
|
||||
expect(stats.topics.events).toBe(2);
|
||||
expect(stats.topics.posts).toBe(1);
|
||||
});
|
||||
|
||||
test("should clean up topics when last subscriber leaves", () => {
|
||||
const mockRes = { write: jest.fn() };
|
||||
|
||||
sseService.addClient(userId, mockRes);
|
||||
sseService.subscribe(userId, ["events"]);
|
||||
|
||||
let stats = sseService.getStats();
|
||||
expect(stats.totalTopics).toBe(1);
|
||||
|
||||
sseService.removeClient(userId);
|
||||
stats = sseService.getStats();
|
||||
expect(stats.totalTopics).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
const request = require("supertest");
|
||||
const socketIoClient = require("socket.io-client");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { createServer } = require("http");
|
||||
const { Server } = require("socket.io");
|
||||
|
||||
// Create test server with Socket.IO
|
||||
const createTestServer = () => {
|
||||
const app = require("express")();
|
||||
const server = createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO authentication middleware
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth.token;
|
||||
if (!token) {
|
||||
return next(new Error("Authentication error"));
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || "test_secret");
|
||||
socket.userId = decoded.user.id;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(new Error("Authentication error"));
|
||||
}
|
||||
});
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("User connected:", socket.userId);
|
||||
|
||||
// Join event rooms
|
||||
socket.on("joinEvent", (eventId) => {
|
||||
socket.join(`event_${eventId}`);
|
||||
socket.emit("joinedEvent", { eventId });
|
||||
});
|
||||
|
||||
// Leave event rooms
|
||||
socket.on("leaveEvent", (eventId) => {
|
||||
socket.leave(`event_${eventId}`);
|
||||
socket.emit("leftEvent", { eventId });
|
||||
});
|
||||
|
||||
// Handle event updates
|
||||
socket.on("eventUpdate", (data) => {
|
||||
socket.to(`event_${data.eventId}`).emit("eventUpdate", data);
|
||||
});
|
||||
|
||||
// Handle new posts
|
||||
socket.on("newPost", (data) => {
|
||||
socket.broadcast.emit("newPost", data);
|
||||
});
|
||||
|
||||
// Handle task updates
|
||||
socket.on("taskUpdate", (data) => {
|
||||
socket.broadcast.emit("taskUpdate", data);
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("User disconnected:", socket.userId);
|
||||
});
|
||||
});
|
||||
|
||||
return { server, io };
|
||||
};
|
||||
|
||||
describe("Socket.IO Real-time Features", () => {
|
||||
let server;
|
||||
let io;
|
||||
let clientSocket;
|
||||
let testUser;
|
||||
let authToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test server
|
||||
const testServer = createTestServer();
|
||||
server = testServer.server;
|
||||
io = testServer.io;
|
||||
|
||||
// Start server on random port
|
||||
await new Promise((resolve) => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
// Create mock test user
|
||||
testUser = {
|
||||
_id: "test_user_123",
|
||||
name: "Test User",
|
||||
email: "test@example.com"
|
||||
};
|
||||
|
||||
// Generate auth token
|
||||
authToken = jwt.sign(
|
||||
{ user: { id: testUser._id } },
|
||||
process.env.JWT_SECRET || "test_secret"
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (clientSocket) {
|
||||
clientSocket.disconnect();
|
||||
}
|
||||
io.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
beforeEach((done) => {
|
||||
// Connect client socket with authentication
|
||||
clientSocket = socketIoClient(`http://localhost:${server.address().port}`, {
|
||||
auth: { token: authToken },
|
||||
});
|
||||
|
||||
clientSocket.on("connect", () => {
|
||||
done();
|
||||
});
|
||||
|
||||
clientSocket.on("connect_error", (err) => {
|
||||
done(err);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (clientSocket && clientSocket.connected) {
|
||||
clientSocket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Socket Authentication", () => {
|
||||
test("should connect with valid token", (done) => {
|
||||
expect(clientSocket.connected).toBe(true);
|
||||
done();
|
||||
});
|
||||
|
||||
test("should reject connection with invalid token", (done) => {
|
||||
const invalidSocket = socketIoClient(
|
||||
`http://localhost:${server.address().port}`,
|
||||
{
|
||||
auth: { token: "invalid_token" },
|
||||
}
|
||||
);
|
||||
|
||||
invalidSocket.on("connect_error", (err) => {
|
||||
expect(err.message).toBe("Authentication error");
|
||||
invalidSocket.disconnect();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject connection without token", (done) => {
|
||||
const noTokenSocket = socketIoClient(
|
||||
`http://localhost:${server.address().port}`
|
||||
);
|
||||
|
||||
noTokenSocket.on("connect_error", (err) => {
|
||||
expect(err.message).toBe("Authentication error");
|
||||
noTokenSocket.disconnect();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event Participation", () => {
|
||||
let testEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
testEvent = {
|
||||
_id: "test_event_123",
|
||||
title: "Test Event",
|
||||
description: "Test Description",
|
||||
date: new Date(Date.now() + 86400000), // Tomorrow
|
||||
location: "Test Location",
|
||||
participants: [],
|
||||
};
|
||||
});
|
||||
|
||||
test("should join event room", (done) => {
|
||||
clientSocket.emit("joinEvent", testEvent._id);
|
||||
|
||||
clientSocket.on("joinedEvent", (data) => {
|
||||
expect(data.eventId).toBe(testEvent._id);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test("should receive event updates in room", (done) => {
|
||||
clientSocket.emit("joinEvent", testEvent._id);
|
||||
|
||||
// Create another client to send updates to the room
|
||||
const anotherClient = socketIoClient(`http://localhost:${server.address().port}`, {
|
||||
auth: { token: authToken },
|
||||
});
|
||||
|
||||
anotherClient.on("connect", () => {
|
||||
// Listen for updates from first client
|
||||
clientSocket.on("eventUpdate", (data) => {
|
||||
expect(data.message).toBe("Event status updated to ongoing");
|
||||
anotherClient.disconnect();
|
||||
done();
|
||||
});
|
||||
|
||||
// Join the same event room
|
||||
anotherClient.emit("joinEvent", testEvent._id);
|
||||
|
||||
// Send update from second client (will be broadcast to room)
|
||||
setTimeout(() => {
|
||||
anotherClient.emit("eventUpdate", {
|
||||
eventId: testEvent._id,
|
||||
message: "Event status updated to ongoing",
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
test("should not receive updates for events not joined", (done) => {
|
||||
const anotherEventId = "another_event_456";
|
||||
|
||||
// Listen for updates (should not receive any)
|
||||
let updateReceived = false;
|
||||
clientSocket.on("eventUpdate", () => {
|
||||
updateReceived = true;
|
||||
});
|
||||
|
||||
// Send update for event not joined
|
||||
setTimeout(() => {
|
||||
clientSocket.emit("eventUpdate", {
|
||||
eventId: anotherEventId,
|
||||
message: "This should not be received",
|
||||
});
|
||||
|
||||
// Check after delay that no update was received
|
||||
setTimeout(() => {
|
||||
expect(updateReceived).toBe(false);
|
||||
done();
|
||||
}, 100);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Post Interactions", () => {
|
||||
let testPost;
|
||||
let testEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
testPost = {
|
||||
_id: "test_post_123",
|
||||
user: {
|
||||
userId: testUser._id,
|
||||
name: testUser.name,
|
||||
},
|
||||
content: "Test post content",
|
||||
likes: [],
|
||||
commentsCount: 0,
|
||||
};
|
||||
|
||||
testEvent = {
|
||||
_id: "test_event_123",
|
||||
title: "Test Event",
|
||||
description: "Test Description",
|
||||
date: new Date(Date.now() + 86400000),
|
||||
location: "Test Location",
|
||||
participants: [],
|
||||
};
|
||||
});
|
||||
|
||||
test("should broadcast new posts", (done) => {
|
||||
// Create another client to receive broadcasts
|
||||
const anotherClient = socketIoClient(`http://localhost:${server.address().port}`, {
|
||||
auth: { token: authToken },
|
||||
});
|
||||
|
||||
anotherClient.on("connect", () => {
|
||||
// Listen for new posts
|
||||
anotherClient.on("newPost", (data) => {
|
||||
expect(data.content).toBe("Test broadcast post");
|
||||
anotherClient.disconnect();
|
||||
done();
|
||||
});
|
||||
|
||||
// Send new post from first client
|
||||
clientSocket.emit("newPost", {
|
||||
content: "Test broadcast post",
|
||||
user: testUser
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle multiple event joins", (done) => {
|
||||
const testEvent2 = {
|
||||
_id: "test_event_456",
|
||||
title: "Another Event",
|
||||
description: "Another Description",
|
||||
date: new Date(Date.now() + 86400000),
|
||||
location: "Another Location",
|
||||
participants: [],
|
||||
};
|
||||
|
||||
let joinCount = 0;
|
||||
const checkJoins = () => {
|
||||
joinCount++;
|
||||
if (joinCount === 2) {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
clientSocket.on("joinedEvent", (data) => {
|
||||
checkJoins();
|
||||
});
|
||||
|
||||
clientSocket.emit("joinEvent", testEvent._id);
|
||||
clientSocket.emit("joinEvent", testEvent2._id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Connection Stability", () => {
|
||||
test("should handle disconnection gracefully", (done) => {
|
||||
// Simple test that disconnection doesn't throw errors
|
||||
expect(() => {
|
||||
clientSocket.disconnect();
|
||||
}).not.toThrow();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(clientSocket.connected).toBe(false);
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
test("should maintain connection under load", async () => {
|
||||
const startTime = Date.now();
|
||||
const messageCount = 50; // Reduced for test stability
|
||||
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
await new Promise((resolve) => {
|
||||
clientSocket.emit("eventUpdate", {
|
||||
eventId: `test_event_${i}`,
|
||||
message: `Test message ${i}`,
|
||||
});
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should complete within reasonable time (less than 5 seconds)
|
||||
expect(duration).toBeLessThan(5000);
|
||||
expect(clientSocket.connected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Concurrent Connections", () => {
|
||||
test("should handle multiple simultaneous connections", async () => {
|
||||
const clients = [];
|
||||
const connectionPromises = [];
|
||||
|
||||
// Create 10 concurrent connections
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const promise = new Promise((resolve) => {
|
||||
const client = socketIoClient(
|
||||
`http://localhost:${server.address().port}`,
|
||||
{
|
||||
auth: { token: authToken },
|
||||
}
|
||||
);
|
||||
|
||||
client.on("connect", () => {
|
||||
clients.push(client);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("connect_error", (err) => {
|
||||
resolve(err);
|
||||
});
|
||||
});
|
||||
|
||||
connectionPromises.push(promise);
|
||||
}
|
||||
|
||||
await Promise.all(connectionPromises);
|
||||
|
||||
// All connections should succeed
|
||||
expect(clients.length).toBe(10);
|
||||
clients.forEach((client) => {
|
||||
expect(client.connected).toBe(true);
|
||||
});
|
||||
|
||||
// Clean up
|
||||
clients.forEach((client) => client.disconnect());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user