From fa7297f4f14af7191c20ed135bb4159492f2023c Mon Sep 17 00:00:00 2001 From: od-hunter Date: Mon, 24 Aug 2026 11:40:59 +0100 Subject: [PATCH] Implement and test real-time Socket.io notifications. Add JWT auth with session and userId support, room-based delivery, offline notification persistence, reconnection handling in useSocket, and integration tests covering auth, broadcast, rooms, reconnect, and offline delivery. --- backend/RealTimeNotifications.tsx | 2 +- backend/SocketServer.ts | 91 +++- backend/jest.config.js | 2 +- backend/package.json | 10 +- .../20240824_add_notifications/migration.sql | 51 +++ backend/prisma/migrations/migration_lock.toml | 3 + backend/schema.prisma | 47 +++ backend/services/NotificationService.ts | 48 ++- .../realtime-notifications.test.ts | 392 ++++++++++++++++++ backend/tests/unit/RealTimeService.test.ts | 78 ++++ backend/tests/unit/hooks/useSocket.test.ts | 198 +++++++++ backend/useSocket.ts | 106 +++-- 12 files changed, 967 insertions(+), 61 deletions(-) create mode 100644 backend/prisma/migrations/20240824_add_notifications/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/tests/integration/realtime-notifications.test.ts create mode 100644 backend/tests/unit/RealTimeService.test.ts create mode 100644 backend/tests/unit/hooks/useSocket.test.ts diff --git a/backend/RealTimeNotifications.tsx b/backend/RealTimeNotifications.tsx index 997982cc..5814b7c8 100644 --- a/backend/RealTimeNotifications.tsx +++ b/backend/RealTimeNotifications.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { useSocket } from '../hooks/useSocket'; +import { useSocket } from './useSocket'; import { toast } from 'react-hot-toast'; // Assuming react-hot-toast is used interface Props { diff --git a/backend/SocketServer.ts b/backend/SocketServer.ts index 079c557b..84e28371 100644 --- a/backend/SocketServer.ts +++ b/backend/SocketServer.ts @@ -14,6 +14,10 @@ import { Server as HttpServer } from 'http'; import { Server, Socket } from 'socket.io'; import jwt from 'jsonwebtoken'; +import { PrismaClient } from '@prisma/client'; +import { AuthenticationService } from './services/AuthenticationService'; + +const prisma = new PrismaClient(); // ─── Types ──────────────────────────────────────────────────────────────────── @@ -47,6 +51,15 @@ export const ROOMS = { notifications: 'room_notifications', } as const; +/** JWT payload shapes supported by socket authentication. */ +interface SocketJwtPayload { + userId?: string; + id?: string; + email?: string; + role?: string; + sessionId?: string; +} + /** Canonical event names emitted by the server. */ export const SERVER_EVENTS = { NOTIFICATION: 'notification', @@ -126,6 +139,16 @@ export class SocketServer { return SocketServer.instance; } + /** + * Reset the singleton instance. Intended for test isolation only. + */ + public static resetForTesting(): void { + if (SocketServer.instance) { + SocketServer.instance.shutdown(); + delete (SocketServer as { instance?: SocketServer }).instance; + } + } + /** * Return the raw Socket.IO `Server` instance. * Throws if `getInstance(httpServer)` has not been called yet. @@ -142,8 +165,7 @@ export class SocketServer { // ─── Authentication middleware ───────────────────────────────────────────── private setupAuthMiddleware(): void { - this.io.use((socket: AuthSocket, next) => { - // Accept token from handshake auth object or Authorization header + this.io.use(async (socket: AuthSocket, next) => { const token = socket.handshake.auth?.token || socket.handshake.headers?.authorization?.replace(/^Bearer\s+/i, ''); @@ -159,12 +181,11 @@ export class SocketServer { } try { - const decoded = jwt.verify(token, secret) as AuthenticatedUser & Record; - socket.user = { - id: decoded.id, - email: decoded.email, - role: decoded.role, - }; + const user = await this.authenticateToken(token, secret); + if (!user) { + return next(new Error('Authentication error: invalid token')); + } + socket.user = user; next(); } catch (err) { const message = @@ -176,6 +197,57 @@ export class SocketServer { }); } + /** + * Resolve the authenticated user from a JWT. + * Supports production session tokens, legacy id/email/role payloads, and test tokens. + */ + private async authenticateToken( + token: string, + secret: string + ): Promise { + const decoded = jwt.verify(token, secret) as SocketJwtPayload & jwt.JwtPayload; + const userId = decoded.userId ?? decoded.id; + + if (!userId) { + return null; + } + + // Production path: validate active session when sessionId is present + if (decoded.sessionId) { + const authService = new AuthenticationService(); + const result = await authService.verifyToken(token); + if (result.user) { + return { + id: result.user.id, + email: result.user.email, + role: result.user.role, + }; + } + return null; + } + + // Test / legacy tokens that include email and role directly in the JWT + if (decoded.email && decoded.role) { + return { + id: userId, + email: decoded.email, + role: decoded.role, + }; + } + + // Fallback: load user profile from the database + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return null; + } + + return { + id: user.id, + email: user.email, + role: user.role, + }; + } + // ─── Connection handlers ─────────────────────────────────────────────────── private setupConnectionHandlers(): void { @@ -223,6 +295,9 @@ export class SocketServer { // Auto-join the user's private room this.joinRoom(socket, info, ROOMS.user(socket.user.id)); + // Auto-join the notifications room for system-wide alerts + this.joinRoom(socket, info, ROOMS.notifications); + // Auto-join the admin room for privileged users if (socket.user.role === 'ADMIN' || socket.user.role === 'SUPER_ADMIN') { this.joinRoom(socket, info, ROOMS.admins); diff --git a/backend/jest.config.js b/backend/jest.config.js index 0e819559..87319efa 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -26,7 +26,7 @@ module.exports = { ], setupFilesAfterEnv: ['/tests/setup.ts'], testTimeout: 10000, - moduleNameMapping: { + moduleNameMapper: { '^@/(.*)$': '/src/$1' }, globalSetup: '/tests/globalSetup.ts', diff --git a/backend/package.json b/backend/package.json index 78890ce5..591c0eab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,8 @@ "connect-redis": "^7.1.0", "joi": "^17.11.0", "uuid": "^9.0.1", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", @@ -104,7 +106,6 @@ "typescript": "^5.2.2", "prisma": "^5.6.0", "ts-node": "^10.9.1", - "@types/axios": "^0.14.0", "swagger-stats": "^0.99.7", "openapi-schema-validator": "^12.1.0", "jest": "^29.7.0", @@ -114,20 +115,15 @@ "eslint": "^8.55.0", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", - "@types/jest-environment-jsdom": "^29.5.1", - "@types/performance-now": "^2.0.2", "@types/web3": "^1.2.2", "@types/rss": "^0.0.32", "@types/xlsx": "^0.0.36", - "@types/isomorphic-dompurify": "^2.0.0", "cypress": "^13.6.0", "cypress-visual-regression": "^5.0.0", "cypress-mochawesome-reporter": "^3.6.0", "cypress-xpath": "^2.0.1", "playwright": "^1.40.0", - "@playwright/test": "^1.40.0", - "@types/web3": "^1.2.2", - "@types/rss": "^0.0.32" + "@playwright/test": "^1.40.0" }, "scripts": { "dev": "ts-node server.ts", diff --git a/backend/prisma/migrations/20240824_add_notifications/migration.sql b/backend/prisma/migrations/20240824_add_notifications/migration.sql new file mode 100644 index 00000000..b7d5d65a --- /dev/null +++ b/backend/prisma/migrations/20240824_add_notifications/migration.sql @@ -0,0 +1,51 @@ +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "data" JSONB NOT NULL DEFAULT '{}', + "priority" TEXT NOT NULL DEFAULT 'MEDIUM', + "category" TEXT, + "actionUrl" TEXT, + "actionText" TEXT, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "readAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "sound" TEXT, + "icon" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification_preferences" ( + "userId" TEXT NOT NULL, + "email" BOOLEAN NOT NULL DEFAULT true, + "sms" BOOLEAN NOT NULL DEFAULT false, + "push" BOOLEAN NOT NULL DEFAULT true, + "inApp" BOOLEAN NOT NULL DEFAULT true, + "soundEnabled" BOOLEAN NOT NULL DEFAULT true, + "desktopNotifications" BOOLEAN NOT NULL DEFAULT true, + "quietHours" JSONB, + "categories" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE INDEX "notifications_userId_idx" ON "notifications"("userId"); + +-- CreateIndex +CREATE INDEX "notifications_userId_isRead_idx" ON "notifications"("userId", "isRead"); + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..99e4f200 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/backend/schema.prisma b/backend/schema.prisma index 7493ab10..b4f22761 100644 --- a/backend/schema.prisma +++ b/backend/schema.prisma @@ -26,6 +26,8 @@ model User { bills Bill[] payments Payment[] sessions UserSession[] + notifications Notification[] + notificationPreference NotificationPreference? @@map("users") } @@ -138,4 +140,49 @@ enum PaymentMethod { STELLAR CREDIT_CARD BANK_TRANSFER +} + +// In-app notifications persisted for offline delivery +model Notification { + id String @id @default(cuid()) + userId String + type String + title String + message String + data Json @default("{}") + priority String @default("MEDIUM") + category String? + actionUrl String? + actionText String? + isRead Boolean @default(false) + readAt DateTime? + expiresAt DateTime? + sound String? + icon String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([userId, isRead]) + @@map("notifications") +} + +model NotificationPreference { + userId String @id + email Boolean @default(true) + sms Boolean @default(false) + push Boolean @default(true) + inApp Boolean @default(true) + soundEnabled Boolean @default(true) + desktopNotifications Boolean @default(true) + quietHours Json? + categories Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("notification_preferences") } \ No newline at end of file diff --git a/backend/services/NotificationService.ts b/backend/services/NotificationService.ts index 8b8d586c..ccb4cbcb 100644 --- a/backend/services/NotificationService.ts +++ b/backend/services/NotificationService.ts @@ -97,12 +97,10 @@ export class RealTimeNotificationService { const userId: string | undefined = (socket as any).user?.id; if (!userId) return; - // Deliver any queued notifications immediately on connect - const queued = this.notificationQueue.get(userId) ?? []; - if (queued.length > 0) { - socket.emit('notifications', queued); - this.notificationQueue.delete(userId); - } + // Deliver persisted and queued notifications when the user reconnects + this.deliverPendingNotifications(userId, socket).catch((err) => { + console.error('[NotificationService] deliverPendingNotifications error:', err); + }); // Push current unread count this.getUnreadCount(userId).then((count) => { @@ -194,6 +192,44 @@ export class RealTimeNotificationService { return Promise.all(notifications.map((n) => this.sendNotification(n))); } + /** + * Deliver unread notifications from the database plus any in-memory queue + * entries that were created while the user was offline in this process. + */ + async deliverPendingNotifications(userId: string, socket: { emit: (event: string, data: unknown) => void }): Promise { + const unreadFromDb = await this.getUserNotifications(userId, { + unreadOnly: true, + limit: 100, + }); + + const queued = this.notificationQueue.get(userId) ?? []; + const seenIds = new Set(unreadFromDb.map((notification) => notification.id)); + const merged = [ + ...unreadFromDb, + ...queued.filter((notification) => !notification.id || !seenIds.has(notification.id)), + ]; + + if (merged.length > 0) { + socket.emit('notifications', merged); + } + + this.notificationQueue.delete(userId); + } + + /** + * Expose the in-memory offline queue for testing. + */ + getQueuedNotifications(userId: string): NotificationData[] { + return [...(this.notificationQueue.get(userId) ?? [])]; + } + + /** + * Reset the in-memory offline queue. Intended for test isolation only. + */ + resetQueueForTesting(): void { + this.notificationQueue.clear(); + } + // ─── Database helpers ───────────────────────────────────────────────────── async getUserNotifications( diff --git a/backend/tests/integration/realtime-notifications.test.ts b/backend/tests/integration/realtime-notifications.test.ts new file mode 100644 index 00000000..fd2779b3 --- /dev/null +++ b/backend/tests/integration/realtime-notifications.test.ts @@ -0,0 +1,392 @@ +/** + * @jest-environment node + */ + +import http from 'http'; +import { io as ioClient, Socket as ClientSocket } from 'socket.io-client'; +import jwt from 'jsonwebtoken'; +import { + SocketServer, + SERVER_EVENTS, + ROOMS, + CLIENT_EVENTS, +} from '../../SocketServer'; +import { RealTimeService, NotificationType } from '../../RealTimeService'; +import { RealTimeNotificationService } from '../../services/NotificationService'; +import { prisma } from '../setup'; + +describe('Real-time notifications integration', () => { + let httpServer: http.Server; + let port: number; + let socketServer: SocketServer; + let notificationService: RealTimeNotificationService; + + const JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret_key_for_testing_only'; + + function createToken(payload: { + userId: string; + email?: string; + role?: string; + sessionId?: string; + }): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); + } + + function connectClient( + token: string | null, + options: { captureRooms?: boolean } = {} + ): Promise { + return new Promise((resolve, reject) => { + const joinedRooms: string[] = []; + const client = ioClient(`http://localhost:${port}`, { + auth: token ? { token } : {}, + transports: ['websocket'], + forceNew: true, + reconnection: false, + }); + + if (options.captureRooms) { + client.on(SERVER_EVENTS.ROOM_JOINED, (payload: { room: string }) => { + joinedRooms.push(payload.room); + }); + } + + const timeout = setTimeout(() => { + client.disconnect(); + reject(new Error('Connection timeout')); + }, 5000); + + client.on('connect', () => { + clearTimeout(timeout); + if (options.captureRooms) { + resolve({ client, joinedRooms }); + } else { + resolve(client); + } + }); + + client.on('connect_error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + } + + function waitForEvent( + client: ClientSocket, + event: string, + timeoutMs = 5000 + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for event: ${event}`)); + }, timeoutMs); + + client.once(event, (payload: T) => { + clearTimeout(timeout); + resolve(payload); + }); + }); + } + + beforeAll((done) => { + process.env.JWT_SECRET = JWT_SECRET; + httpServer = http.createServer(); + socketServer = SocketServer.getInstance(httpServer); + notificationService = RealTimeNotificationService.getInstance(); + notificationService.initialize(); + + httpServer.listen(0, () => { + port = (httpServer.address() as { port: number }).port; + done(); + }); + }); + + afterAll((done) => { + notificationService.resetQueueForTesting(); + SocketServer.resetForTesting(); + httpServer.close(done); + }); + + afterEach(() => { + notificationService.resetQueueForTesting(); + }); + + describe('connection authentication', () => { + it('rejects connections without a token', async () => { + await expect(connectClient(null)).rejects.toThrow(); + }); + + it('rejects connections with an invalid token', async () => { + await expect(connectClient('not-a-valid-token')).rejects.toThrow(); + }); + + it('accepts connections with a valid JWT containing userId', async () => { + const token = createToken({ + userId: 'user-auth-test', + email: 'auth@test.com', + role: 'USER', + }); + + const client = await connectClient(token); + expect(client.connected).toBe(true); + client.disconnect(); + }); + + it('auto-joins the user private room and notifications room', async () => { + const userId = 'user-room-test'; + const token = createToken({ + userId, + email: 'rooms@test.com', + role: 'USER', + }); + + const { client, joinedRooms } = (await connectClient(token, { + captureRooms: true, + })) as { client: ClientSocket; joinedRooms: string[] }; + + expect(joinedRooms).toEqual( + expect.arrayContaining([ROOMS.user(userId), ROOMS.notifications]) + ); + + client.disconnect(); + }); + }); + + describe('event broadcasting', () => { + it('broadcasts events to all connected clients', async () => { + const tokenA = createToken({ + userId: 'user-broadcast-a', + email: 'a@test.com', + role: 'USER', + }); + const tokenB = createToken({ + userId: 'user-broadcast-b', + email: 'b@test.com', + role: 'USER', + }); + + const clientA = await connectClient(tokenA); + const clientB = await connectClient(tokenB); + + const payloadPromiseA = waitForEvent<{ type: NotificationType }>( + clientA, + SERVER_EVENTS.BROADCAST + ); + const payloadPromiseB = waitForEvent<{ type: NotificationType }>( + clientB, + SERVER_EVENTS.BROADCAST + ); + + RealTimeService.broadcast(NotificationType.SYSTEM_ALERT, { message: 'Hello all' }); + + const [payloadA, payloadB] = await Promise.all([payloadPromiseA, payloadPromiseB]); + + expect(payloadA.type).toBe(NotificationType.SYSTEM_ALERT); + expect(payloadB.type).toBe(NotificationType.SYSTEM_ALERT); + + clientA.disconnect(); + clientB.disconnect(); + }); + + it('delivers user-specific notifications via RealTimeService', async () => { + const userId = 'user-direct-test'; + const token = createToken({ + userId, + email: 'direct@test.com', + role: 'USER', + }); + + const client = await connectClient(token); + const notificationPromise = waitForEvent<{ type: NotificationType; title: string }>( + client, + SERVER_EVENTS.NOTIFICATION + ); + + RealTimeService.sendUserUpdate(userId, NotificationType.PAYMENT_SUCCESS, { + amount: 5000, + transactionId: 'tx-123', + }); + + const notification = await notificationPromise; + expect(notification.type).toBe(NotificationType.PAYMENT_SUCCESS); + expect(notification.title).toBe('Payment Successful'); + + client.disconnect(); + }); + }); + + describe('room-based notifications', () => { + it('delivers system alerts to the notifications room', async () => { + const token = createToken({ + userId: 'user-system-alert', + email: 'alert@test.com', + role: 'USER', + }); + + const client = await connectClient(token); + const alertPromise = waitForEvent<{ message: string; type: NotificationType }>( + client, + SERVER_EVENTS.NOTIFICATION + ); + + RealTimeService.sendSystemAlert('Scheduled maintenance at midnight'); + + const alert = await alertPromise; + expect(alert.type).toBe(NotificationType.SYSTEM_ALERT); + expect(alert.message).toBe('Scheduled maintenance at midnight'); + + client.disconnect(); + }); + + it('restricts admin rooms to privileged users', async () => { + const userToken = createToken({ + userId: 'user-non-admin', + email: 'user@test.com', + role: 'USER', + }); + const adminToken = createToken({ + userId: 'user-admin', + email: 'admin@test.com', + role: 'ADMIN', + }); + + const userClient = await connectClient(userToken); + const adminClient = await connectClient(adminToken); + + const adminJoined = waitForEvent<{ room: string }>(adminClient, SERVER_EVENTS.ROOM_JOINED); + adminClient.emit(CLIENT_EVENTS.JOIN_ROOM, ROOMS.admins); + const adminRoomEvent = await adminJoined; + expect(adminRoomEvent.room).toBe(ROOMS.admins); + + const userErrors: string[] = []; + userClient.on(SERVER_EVENTS.ERROR, (payload: { message: string }) => { + userErrors.push(payload.message); + }); + + userClient.emit(CLIENT_EVENTS.JOIN_ROOM, ROOMS.admins); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(userErrors.some((message) => message.includes('Not authorised'))).toBe(true); + + userClient.disconnect(); + adminClient.disconnect(); + }); + }); + + describe('reconnection handling', () => { + it('allows clients to reconnect with the same token', async () => { + const userId = 'user-reconnect-test'; + const token = createToken({ + userId, + email: 'reconnect@test.com', + role: 'USER', + }); + + const firstClient = await connectClient(token); + firstClient.disconnect(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const secondClient = await connectClient(token); + expect(secondClient.connected).toBe(true); + expect(socketServer.isUserOnline(userId)).toBe(true); + + secondClient.disconnect(); + }); + + it('responds to ping events after reconnecting', async () => { + const token = createToken({ + userId: 'user-ping-test', + email: 'ping@test.com', + role: 'USER', + }); + + const client = await connectClient(token); + client.disconnect(); + + const reconnected = await connectClient(token); + const pongPromise = waitForEvent<{ timestamp: number }>(reconnected, SERVER_EVENTS.PONG); + + reconnected.emit(CLIENT_EVENTS.PING); + const pong = await pongPromise; + + expect(typeof pong.timestamp).toBe('number'); + reconnected.disconnect(); + }); + }); + + describe('notification persistence for offline users', () => { + async function createUser(email: string) { + const bcrypt = await import('bcryptjs'); + return prisma.user.create({ + data: { + email, + password: await bcrypt.hash('password123', 10), + role: 'USER', + }, + }); + } + + it('queues notifications while offline and delivers them on reconnect', async () => { + const user = await createUser('offline-persist@test.com'); + + const notificationId = await notificationService.sendNotification({ + userId: user.id, + type: 'INFO', + title: 'Offline message', + message: 'You were offline when this was sent', + priority: 'MEDIUM', + }); + + expect(notificationService.getQueuedNotifications(user.id)).toHaveLength(1); + + const token = createToken({ + userId: user.id, + email: user.email, + role: user.role, + }); + + const client = await connectClient(token); + const pendingPromise = waitForEvent>( + client, + 'notifications' + ); + + const pending = await pendingPromise; + expect(pending.some((item) => item.id === notificationId)).toBe(true); + expect(pending.some((item) => item.title === 'Offline message')).toBe(true); + expect(notificationService.getQueuedNotifications(user.id)).toHaveLength(0); + + client.disconnect(); + }); + + it('delivers unread notifications from the database on connect', async () => { + const user = await createUser('db-persist@test.com'); + + await notificationService.sendNotification({ + userId: user.id, + type: 'SUCCESS', + title: 'Persisted notification', + message: 'Stored in database', + priority: 'LOW', + }); + + notificationService.resetQueueForTesting(); + + const token = createToken({ + userId: user.id, + email: user.email, + role: user.role, + }); + + const client = await connectClient(token); + const pendingPromise = waitForEvent>(client, 'notifications'); + const pending = await pendingPromise; + + expect(pending.some((item) => item.title === 'Persisted notification')).toBe(true); + + client.disconnect(); + }); + }); +}); diff --git a/backend/tests/unit/RealTimeService.test.ts b/backend/tests/unit/RealTimeService.test.ts new file mode 100644 index 00000000..073ecff2 --- /dev/null +++ b/backend/tests/unit/RealTimeService.test.ts @@ -0,0 +1,78 @@ +import { RealTimeService, NotificationType } from '../../RealTimeService'; +import { SocketServer, SERVER_EVENTS, ROOMS } from '../../SocketServer'; + +jest.mock('../../SocketServer', () => { + const mockSocketServer = { + sendNotification: jest.fn(), + emitToUser: jest.fn(), + broadcast: jest.fn(), + emitToRoom: jest.fn(), + }; + + return { + SocketServer: { + getInstance: jest.fn(() => mockSocketServer), + }, + SERVER_EVENTS: { + BROADCAST: 'broadcast', + NOTIFICATION: 'notification', + }, + ROOMS: { + notifications: 'room_notifications', + }, + }; +}); + +describe('RealTimeService', () => { + const mockSocketServer = SocketServer.getInstance() as jest.Mocked< + ReturnType + >; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sends user-specific notifications with typed payload', () => { + RealTimeService.sendUserUpdate('user-1', NotificationType.PAYMENT_SUCCESS, { + amount: 2500, + }); + + expect(mockSocketServer.sendNotification).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + type: NotificationType.PAYMENT_SUCCESS, + title: 'Payment Successful', + }) + ); + expect(mockSocketServer.emitToUser).toHaveBeenCalledWith( + 'user-1', + 'payment_success', + { amount: 2500 } + ); + }); + + it('broadcasts messages to all connected clients', () => { + RealTimeService.broadcast(NotificationType.SYSTEM_ALERT, { message: 'Update' }); + + expect(mockSocketServer.broadcast).toHaveBeenCalledWith( + SERVER_EVENTS.BROADCAST, + expect.objectContaining({ + type: NotificationType.SYSTEM_ALERT, + data: { message: 'Update' }, + }) + ); + }); + + it('sends system alerts to the notifications room', () => { + RealTimeService.sendSystemAlert('Maintenance window'); + + expect(mockSocketServer.emitToRoom).toHaveBeenCalledWith( + ROOMS.notifications, + SERVER_EVENTS.NOTIFICATION, + expect.objectContaining({ + type: NotificationType.SYSTEM_ALERT, + message: 'Maintenance window', + }) + ); + }); +}); diff --git a/backend/tests/unit/hooks/useSocket.test.ts b/backend/tests/unit/hooks/useSocket.test.ts new file mode 100644 index 00000000..cde60877 --- /dev/null +++ b/backend/tests/unit/hooks/useSocket.test.ts @@ -0,0 +1,198 @@ +import { renderHook, act } from '@testing-library/react'; +import { useSocket } from '../../../useSocket'; + +jest.mock('socket.io-client', () => { + const mockSocket = { + on: jest.fn(), + off: jest.fn(), + disconnect: jest.fn(), + connected: true, + }; + + return { + io: jest.fn(() => mockSocket), + }; +}); + +describe('useSocket Hook', () => { + const mockToken = 'test-auth-token-123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initial state', () => { + it('initializes with correct default state when no token is provided', () => { + const { result } = renderHook(() => useSocket({ token: null })); + + expect(result.current).toMatchObject({ + socket: null, + isConnected: false, + isReconnecting: false, + reconnectionAttempt: 0, + lastMessage: null, + lastError: null, + }); + expect(typeof result.current.subscribe).toBe('function'); + expect(typeof result.current.cleanup).toBe('function'); + }); + + it('initializes socket when token is provided', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + expect(result.current.socket).not.toBeNull(); + }); + }); + + describe('state transitions for connection events', () => { + it('updates state when connected', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + const connectHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'connect' + )?.[1]; + + if (connectHandler) { + act(() => { + connectHandler(); + }); + } + + expect(result.current.isConnected).toBe(true); + expect(result.current.isReconnecting).toBe(false); + expect(result.current.reconnectionAttempt).toBe(0); + expect(result.current.lastError).toBeNull(); + }); + + it('updates state when disconnected', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + const connectHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'connect' + )?.[1]; + const disconnectHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'disconnect' + )?.[1]; + + if (connectHandler) { + act(() => connectHandler()); + } + + if (disconnectHandler) { + act(() => disconnectHandler('io server disconnect')); + } + + expect(result.current.isConnected).toBe(false); + }); + + it('updates state when reconnecting starts', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + const reconnectingHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'reconnecting' + )?.[1]; + + if (reconnectingHandler) { + act(() => reconnectingHandler(2)); + } + + expect(result.current.isReconnecting).toBe(true); + expect(result.current.reconnectionAttempt).toBe(2); + }); + + it('updates state when reconnection succeeds', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + const reconnectingHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'reconnecting' + )?.[1]; + const reconnectHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'reconnect' + )?.[1]; + + if (reconnectingHandler) { + act(() => reconnectingHandler(3)); + } + + if (reconnectHandler) { + act(() => reconnectHandler(3)); + } + + expect(result.current.isConnected).toBe(true); + expect(result.current.isReconnecting).toBe(false); + expect(result.current.reconnectionAttempt).toBe(0); + }); + + it('updates state when reconnection fails', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + const reconnectFailedHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'reconnect_failed' + )?.[1]; + + if (reconnectFailedHandler) { + act(() => reconnectFailedHandler()); + } + + expect(result.current.isReconnecting).toBe(false); + expect(result.current.lastError).toBe('Reconnection failed after all attempts'); + }); + + it('updates lastError when connect error occurs', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + const testError = new Error('Connection refused'); + const connectErrorHandler = (socket?.on as jest.Mock).mock.calls.find( + ([event]) => event === 'connect_error' + )?.[1]; + + if (connectErrorHandler) { + act(() => connectErrorHandler(testError)); + } + + expect(result.current.lastError).toBe(testError.message); + expect(result.current.isConnected).toBe(false); + }); + }); + + describe('subscribe function', () => { + it('registers an event listener and returns a cleanup function', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + const testCallback = jest.fn(); + + const cleanup = result.current.subscribe('test-event', testCallback); + + expect(typeof cleanup).toBe('function'); + expect(socket?.on).toHaveBeenCalledWith('test-event', testCallback); + }); + }); + + describe('cleanup', () => { + it('cleans up listeners and disconnects when cleanup is called', () => { + const { result } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + act(() => { + result.current.cleanup(); + }); + + expect(socket?.disconnect).toHaveBeenCalled(); + }); + + it('cleans up when the hook unmounts', () => { + const { result, unmount } = renderHook(() => useSocket({ token: mockToken })); + const socket = result.current.socket; + + unmount(); + + expect(socket?.disconnect).toHaveBeenCalled(); + expect(socket?.off).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/useSocket.ts b/backend/useSocket.ts index 005cee2a..421230df 100644 --- a/backend/useSocket.ts +++ b/backend/useSocket.ts @@ -1,120 +1,150 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { io, Socket } from 'socket.io-client'; -interface SocketConfig { - url?: string; - token?: string; - autoConnect?: boolean; -} - +/** + * Custom React hook for managing Socket.IO connection with automatic reconnection + * using exponential backoff and graceful reconnection event handling. + */ export const useSocket = ({ token }: { token: string | null }) => { const socketRef = useRef(null); + const cleanupRef = useRef<(() => void)[]>([]); + const [isConnected, setIsConnected] = useState(false); + const [isReconnecting, setIsReconnecting] = useState(false); + const [reconnectionAttempt, setReconnectionAttempt] = useState(0); const [lastMessage, setLastMessage] = useState(null); - const cleanupRef = useRef<(() => void)[]>([]); + const [lastError, setLastError] = useState(null); useEffect(() => { if (!token) return; const socketUrl = process.env.REACT_APP_API_URL || 'http://localhost:3001'; - - // Initialize socket connection + socketRef.current = io(socketUrl, { auth: { token }, reconnection: true, - reconnectionAttempts: 5, + reconnectionAttempts: Infinity, reconnectionDelay: 1000, - transports: ['websocket', 'polling'] + reconnectionDelayMax: 30000, + randomizationFactor: 0.5, + transports: ['websocket', 'polling'], }); const socket = socketRef.current; - // Connection events const onConnect = () => { setIsConnected(true); - console.log('✅ Socket connected'); + setIsReconnecting(false); + setReconnectionAttempt(0); + setLastError(null); }; - const onDisconnect = (reason: string) => { + const onDisconnect = () => { setIsConnected(false); - console.log('❌ Socket disconnected:', reason); }; const onConnectError = (err: Error) => { - console.error('Socket connection error:', err.message); + setLastError(err.message); setIsConnected(false); }; + const onReconnecting = (attemptNumber: number) => { + setIsReconnecting(true); + setReconnectionAttempt(attemptNumber); + }; + + const onReconnect = () => { + setIsConnected(true); + setIsReconnecting(false); + setReconnectionAttempt(0); + setLastError(null); + }; + + const onReconnectFailed = () => { + setIsReconnecting(false); + setLastError('Reconnection failed after all attempts'); + }; + + const onReconnectError = (err: Error) => { + setLastError(err.message); + }; + const onNotification = (data: any) => { setLastMessage(data); }; - // Register event listeners socket.on('connect', onConnect); socket.on('disconnect', onDisconnect); socket.on('connect_error', onConnectError); + socket.on('reconnecting', onReconnecting); + socket.on('reconnect', onReconnect); + socket.on('reconnect_failed', onReconnectFailed); + socket.on('reconnect_error', onReconnectError); socket.on('notification', onNotification); - // Cleanup function return () => { - // Remove all event listeners socket.off('connect', onConnect); socket.off('disconnect', onDisconnect); socket.off('connect_error', onConnectError); + socket.off('reconnecting', onReconnecting); + socket.off('reconnect', onReconnect); + socket.off('reconnect_failed', onReconnectFailed); + socket.off('reconnect_error', onReconnectError); socket.off('notification', onNotification); - // Clean up any additional event listeners created by subscribe - cleanupRef.current.forEach(cleanup => cleanup()); + cleanupRef.current.forEach((cleanup) => cleanup()); cleanupRef.current = []; - // Disconnect socket properly - if (socketRef.current && socketRef.current.connected) { + if (socketRef.current) { socketRef.current.disconnect(); } - - // Clear the reference + socketRef.current = null; setIsConnected(false); + setIsReconnecting(false); + setReconnectionAttempt(0); + setLastError(null); }; }, [token]); - // Helper to subscribe to specific events with proper cleanup const subscribe = useCallback((event: string, callback: (data: any) => void) => { if (!socketRef.current) { - return () => {}; // Return empty cleanup function if socket doesn't exist + return () => {}; } const socket = socketRef.current; socket.on(event, callback); - // Create cleanup function for this specific event const cleanup = () => { socket.off(event, callback); }; - // Store cleanup function for later use cleanupRef.current.push(cleanup); - return cleanup; }, []); - // Manual cleanup function for external use const cleanup = useCallback(() => { - cleanupRef.current.forEach(cleanup => cleanup()); + cleanupRef.current.forEach((fn) => fn()); cleanupRef.current = []; - - if (socketRef.current && socketRef.current.connected) { + + if (socketRef.current) { socketRef.current.disconnect(); } socketRef.current = null; setIsConnected(false); + setIsReconnecting(false); + setReconnectionAttempt(0); + setLastError(null); }, []); - return { - socket: socketRef.current, - isConnected, + return { + socket: socketRef.current, + isConnected, + isReconnecting, + reconnectionAttempt, lastMessage, + lastError, subscribe, - cleanup + cleanup, }; };