- 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>
227 lines
6.9 KiB
JavaScript
227 lines
6.9 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|