- Connect to /notifications namespace (matches backend NotificationsGateway) - Pass JWT token in Socket.IO auth handshake for proper authentication - Listen for server-pushed notification:unread-count to sync badge - Show sonner toast on notification:new events - Add setUnreadCount action to notifications store - Add E2E round-trip tests (auth connect, reject invalid, multi-device) - Fix inquiry handler test: event name inquiry.created → inquiry.received Co-Authored-By: Paperclip <noreply@paperclip.ing>
106 lines
3.2 KiB
TypeScript
106 lines
3.2 KiB
TypeScript
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<string>((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();
|
|
}
|
|
});
|
|
});
|