import { test, expect } from '@playwright/test'; import { io, type Socket } from 'socket.io-client'; import { registerUser } from '../fixtures'; /** * E2E tests for the NotificationsGateway WebSocket round-trip. * * Covers: * - JWT auth handshake on the `/notifications` namespace * - `notification:unread-count` pushed on connect * - Rejection of unauthenticated connections */ /** Resolve the Socket.IO base URL from the API base URL. */ function wsBaseUrl(): string { const apiBase = process.env['API_BASE_URL'] ?? 'http://localhost:3001/api/v1/'; return apiBase.replace(/\/api\/v1\/?$/, ''); } /** * Helper — connect to the /notifications namespace with a JWT token * and return a promise that resolves after the first `notification:unread-count` * event or rejects on timeout / connect_error. */ function connectSocket(token: string): Promise<{ socket: Socket; unreadCount: number }> { return new Promise((resolve, reject) => { const socket = io(`${wsBaseUrl()}/notifications`, { auth: { token }, transports: ['websocket'], reconnection: false, timeout: 5000, }); const timer = setTimeout(() => { socket.disconnect(); reject(new Error('WS connection timed out')); }, 10_000); socket.on('notification:unread-count', (data: { unreadCount: number }) => { clearTimeout(timer); resolve({ socket, unreadCount: data.unreadCount }); }); socket.on('connect_error', (err) => { clearTimeout(timer); socket.disconnect(); reject(new Error(`WS connect_error: ${err.message}`)); }); }); } test.describe('Notifications WebSocket', () => { test('authenticated user connects and receives unread count', async ({ request }) => { const { accessToken } = await registerUser(request); const { socket, unreadCount } = await connectSocket(accessToken); try { expect(typeof unreadCount).toBe('number'); expect(unreadCount).toBeGreaterThanOrEqual(0); } finally { socket.disconnect(); } }); test('unauthenticated connection is rejected', async () => { const socket = io(`${wsBaseUrl()}/notifications`, { auth: { token: 'invalid-token-xyz' }, transports: ['websocket'], reconnection: false, timeout: 5000, }); const disconnected = new Promise((resolve) => { socket.on('disconnect', (reason) => resolve(reason)); socket.on('connect_error', (err) => { socket.disconnect(); resolve(`connect_error: ${err.message}`); }); }); const reason = await disconnected; // The gateway should disconnect or reject the connection expect(reason).toBeTruthy(); socket.disconnect(); }); test('multi-device: two sockets for same user both receive unread count', async ({ request, }) => { const { accessToken } = await registerUser(request); const [conn1, conn2] = await Promise.all([ connectSocket(accessToken), connectSocket(accessToken), ]); try { expect(conn1.unreadCount).toBeGreaterThanOrEqual(0); expect(conn2.unreadCount).toBeGreaterThanOrEqual(0); } finally { conn1.socket.disconnect(); conn2.socket.disconnect(); } }); });