Skip to content

Commit 5297d26

Browse files
committed
test: websocket testcases
1 parent b789ff9 commit 5297d26

File tree

12 files changed

+1406
-105
lines changed

12 files changed

+1406
-105
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"coverage": "nyc npm run test",
2121
"coverage-ci": "nyc npm run test-ci",
2222
"coverage-ci-migrate": "nyc npm run test-ci-migrate",
23+
"coverage-file": "nyc npm run test-file",
2324
"lint": "eslint package.json src test --ext .js --ext .ts",
2425
"lint-fix": "npm run lint -- --fix",
2526
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ export class Application {
106106
public async stop(): Promise<void> {
107107
this.logger.info('Stopping application instance...');
108108
await util.promisify(this.server.close).bind(this.server)();
109+
if (this.webSocketService) {
110+
await this.webSocketService.close();
111+
}
109112
this.tasks.forEach((task) => task.stop());
110113
await this.connection.destroy();
111114
this.logger.info('Application stopped.');
@@ -240,6 +243,20 @@ export default async function createApp(): Promise<Application> {
240243
await setupAuthentication(tokenHandler, application);
241244

242245
// Initialize WebSocket service
246+
// Close existing instance's server if it exists (e.g., in tests)
247+
try {
248+
const existingInstance = WebSocketService.getInstance();
249+
if (existingInstance.server.listening) {
250+
const l = log4js.getLogger('index');
251+
l.info('Closing existing WebSocket server before creating new instance');
252+
existingInstance.server.close(() => {
253+
l.info('Existing WebSocket server closed');
254+
});
255+
}
256+
} catch {
257+
// No existing instance, continue
258+
}
259+
243260
const webSocketService = new WebSocketService({
244261
tokenHandler,
245262
roleManager: application.roleManager,

src/service/websocket-service.ts

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
RoomRegistration,
3737
WebSocketRequestContext,
3838
} from './websocket/room-policy';
39-
import { InPosGuard, GlobalGuard } from './websocket/event-guards';
39+
import { InPosGuard, ForUserGuard } from './websocket/event-guards';
4040
import { EventRegistry, ResolvedRoom } from './websocket/event-registry';
4141
import { getPointOfSaleRelation } from './websocket/pos-relation-helper';
4242
import RoleManager from '../rbac/role-manager';
@@ -79,6 +79,8 @@ export default class WebSocketService {
7979

8080
private readonly eventRegistry: EventRegistry = new EventRegistry();
8181

82+
private connectionHandlerRegistered: boolean = false;
83+
8284
/**
8385
* Creates a new WebSocketService instance.
8486
* @param options - The service options.
@@ -185,7 +187,14 @@ export default class WebSocketService {
185187
entityId: transaction.pointOfSale.id,
186188
});
187189
}
188-
190+
191+
if (transaction.from?.id) {
192+
rooms.push({
193+
roomName: `user:${transaction.from.id}:transactions`,
194+
entityId: transaction.from.id,
195+
});
196+
}
197+
189198
rooms.push({
190199
roomName: 'transactions:all',
191200
entityId: null,
@@ -194,15 +203,16 @@ export default class WebSocketService {
194203
return rooms;
195204
},
196205
guard: async (transaction, roomContext) => {
197-
if (roomContext.isGlobal) {
198-
return GlobalGuard(transaction, roomContext);
206+
if (roomContext.isGlobal) return true;
207+
208+
switch (roomContext.entityType) {
209+
case 'pos':
210+
return InPosGuard(transaction, roomContext);
211+
case 'user':
212+
return ForUserGuard(transaction, roomContext);
213+
default:
214+
return false;
199215
}
200-
201-
if (roomContext.entityType === 'pos') {
202-
return InPosGuard(transaction, roomContext);
203-
}
204-
205-
return false;
206216
},
207217
});
208218
}
@@ -226,20 +236,41 @@ export default class WebSocketService {
226236
* Initializes the WebSocket server and sets up connection handlers.
227237
*/
228238
public initiateWebSocket(): void {
239+
// Prevent multiple initializations
240+
if (this.connectionHandlerRegistered) {
241+
this.logger.trace('WebSocket connection handler already registered, skipping initialization');
242+
return;
243+
}
244+
229245
if (process.env.NODE_ENV == 'production') {
230246
this.setupAdapter();
231247
} else {
232248
const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT, 10) : 8080;
233249

234-
this.server.listen(port, () => {
235-
this.logger.info(`WebSocket opened on port ${port}.`);
236-
});
250+
// Only start listening if not already listening
251+
if (!this.server.listening) {
252+
this.server.listen(port, () => {
253+
this.logger.info(`WebSocket opened on port ${port}.`);
254+
});
255+
// Handle EADDRINUSE error gracefully (e.g., in tests where port might already be in use)
256+
this.server.on('error', (error: NodeJS.ErrnoException) => {
257+
if (error.code === 'EADDRINUSE') {
258+
this.logger.warn(`Port ${port} is already in use. WebSocket server may already be running.`);
259+
} else {
260+
this.logger.error('WebSocket server error:', error);
261+
}
262+
});
263+
} else {
264+
this.logger.trace(`WebSocket server already listening on port ${port}, skipping listen call`);
265+
}
237266
}
238267

268+
// Register connection handler only once
239269
this.io.on('connection', async (client: Socket) => {
240-
await this.handleAuthentication(client);
241270
this.setupConnectionHandlers(client);
271+
await this.handleAuthentication(client);
242272
});
273+
this.connectionHandlerRegistered = true;
243274
}
244275

245276
/**
@@ -483,4 +514,50 @@ export default class WebSocketService {
483514
public static async emitTransactionCreated(transaction: TransactionResponse): Promise<void> {
484515
await this.getInstance().emitTransactionCreated(transaction);
485516
}
517+
518+
/**
519+
* Closes the WebSocket server and cleans up resources.
520+
* @returns Promise that resolves when the server is closed.
521+
*/
522+
public async close(): Promise<void> {
523+
return new Promise<void>((resolve) => {
524+
if (!this.server.listening) {
525+
resolve();
526+
return;
527+
}
528+
529+
this.io.close(() => {
530+
// Socket.IO's close() already closes the underlying HTTP server,
531+
// but we check if it's still listening before trying to close it again
532+
if (this.server.listening) {
533+
this.server.close((err) => {
534+
if (err) {
535+
const nodeErr = err as NodeJS.ErrnoException;
536+
if (nodeErr.code !== 'ERR_SERVER_NOT_RUNNING') {
537+
this.logger.error('Error closing WebSocket server:', err);
538+
} else {
539+
this.logger.info('WebSocket server closed');
540+
}
541+
} else {
542+
this.logger.info('WebSocket server closed');
543+
}
544+
resolve();
545+
});
546+
} else {
547+
// Server was already closed by io.close()
548+
this.logger.info('WebSocket server closed');
549+
resolve();
550+
}
551+
});
552+
});
553+
}
554+
555+
/**
556+
* Static method for backward compatibility.
557+
* Delegates to the singleton instance.
558+
* @throws Error if WebSocketService has not been initialized.
559+
*/
560+
public static async close(): Promise<void> {
561+
await this.getInstance().close();
562+
}
486563
}

src/service/websocket/event-guards.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ export const InPosGuard: EventGuard<{ pointOfSale?: { id?: number } }> = (eventD
3939
};
4040

4141
/**
42-
* Global guard only matches global rooms (isGlobal === true).
42+
* ForUser guard checks if transaction belongs to the user in the room.
4343
*/
44-
export const GlobalGuard: EventGuard = (_eventData, roomContext) => {
45-
return roomContext.isGlobal;
44+
export const ForUserGuard: EventGuard<{ from?: { id?: number } }> = (eventData, roomContext) => {
45+
if (roomContext.entityType !== 'user' || roomContext.entityId === null) {
46+
return false;
47+
}
48+
return eventData.from?.id === roomContext.entityId;
4649
};

test/unit/controller/authentication-secure-controller.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,19 @@ describe('AuthenticationSecureController', () => {
294294
on: sinon.stub(),
295295
};
296296

297-
// Mock the WebSocketService.io property
298-
sinon.stub(WebSocketService, 'io').value(mockSocketIO);
297+
// Create a mock WebSocketService instance
298+
const mockWebSocketService = {
299+
io: mockSocketIO,
300+
emitQRConfirmed: (qr: QRAuthenticator, token: AuthenticationResponse) => {
301+
mockSocketIO.to(`qr-session-${qr.sessionId}`).emit('qr-confirmed', {
302+
sessionId: qr.sessionId,
303+
token,
304+
});
305+
},
306+
} as any;
307+
308+
// Mock getInstance to return our mock instance
309+
sinon.stub(WebSocketService, 'getInstance').returns(mockWebSocketService);
299310

300311
// Setup service stubs
301312
qrServiceStub = sinon.createStubInstance(QRService);

test/unit/controller/server-settings-controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import ServerSettingsStore from '../../../src/server-settings/server-settings-st
2727
import { expect, request } from 'chai';
2828
import sinon from 'sinon';
2929
import { RbacSeeder, UserSeeder } from '../../seed';
30+
import WebSocketService from '../../../src/service/websocket-service';
31+
import TokenHandler from '../../../src/authentication/token-handler';
32+
import RoleManager from '../../../src/rbac/role-manager';
3033

3134
describe('ServerSettingsController', () => {
3235
let ctx: DefaultContext & {
@@ -70,6 +73,17 @@ describe('ServerSettingsController', () => {
7073
ServerSettingsStore.deleteInstance();
7174
await ServerSettingsStore.getInstance().initialize();
7275

76+
// Initialize WebSocketService for tests that need it
77+
const mockTokenHandler = {} as TokenHandler;
78+
const mockRoleManager = {} as RoleManager;
79+
const webSocketService = new WebSocketService({
80+
tokenHandler: mockTokenHandler,
81+
roleManager: mockRoleManager,
82+
});
83+
// Mock sendMaintenanceMode to avoid actual WebSocket operations in tests
84+
sinon.stub(webSocketService, 'sendMaintenanceMode').returns(undefined);
85+
sinon.stub(WebSocketService, 'getInstance').returns(webSocketService);
86+
7387
ctx = {
7488
...c,
7589
admin,

0 commit comments

Comments
 (0)