@flusys/nestjs-notification 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ import { NotificationType } from '@flusys/nestjs-shared/enums';
2
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
3
+ import { NotificationController } from './notification.controller';
4
+ import { NOTIFICATION_MESSAGES } from '../config';
5
+ function buildNotification(overrides = {}) {
6
+ return {
7
+ id: 'notif-1',
8
+ userId: 'user-uuid-1',
9
+ type: NotificationType.INFO,
10
+ title: 'Title',
11
+ message: 'Message',
12
+ data: null,
13
+ isRead: false,
14
+ readAt: null,
15
+ createdAt: new Date(),
16
+ updatedAt: new Date(),
17
+ ...overrides
18
+ };
19
+ }
20
+ describe('NotificationController', ()=>{
21
+ let controller;
22
+ let mockService;
23
+ let mockHelper;
24
+ beforeEach(()=>{
25
+ mockService = {
26
+ markAsRead: jest.fn(),
27
+ markAllAsRead: jest.fn(),
28
+ getUnreadCount: jest.fn(),
29
+ insert: jest.fn(),
30
+ insertMany: jest.fn(),
31
+ findById: jest.fn(),
32
+ findByIds: jest.fn(),
33
+ update: jest.fn(),
34
+ updateMany: jest.fn(),
35
+ bulkUpsert: jest.fn(),
36
+ getAll: jest.fn(),
37
+ delete: jest.fn()
38
+ };
39
+ mockHelper = {
40
+ send: jest.fn(),
41
+ sendToMany: jest.fn(),
42
+ isUserOnline: jest.fn(),
43
+ getOnlineUserCount: jest.fn()
44
+ };
45
+ controller = new NotificationController(mockService, mockHelper);
46
+ });
47
+ describe('markAsRead', ()=>{
48
+ it('marks the notification as read and returns the mapped response', async ()=>{
49
+ const user = buildMockUser();
50
+ const notification = buildNotification({
51
+ isRead: true
52
+ });
53
+ mockService.markAsRead.mockResolvedValue(notification);
54
+ const result = await controller.markAsRead({
55
+ id: 'notif-1'
56
+ }, user);
57
+ expect(mockService.markAsRead).toHaveBeenCalledWith('notif-1', user);
58
+ expect(result.success).toBe(true);
59
+ expect(result.messageKey).toBe(NOTIFICATION_MESSAGES.MARK_READ_SUCCESS);
60
+ expect(result.data?.id).toBe('notif-1');
61
+ expect(result.data?.isRead).toBe(true);
62
+ });
63
+ it('propagates a not-found error from the service', async ()=>{
64
+ mockService.markAsRead.mockRejectedValue(new Error(NOTIFICATION_MESSAGES.NOT_FOUND));
65
+ await expect(controller.markAsRead({
66
+ id: 'missing'
67
+ }, buildMockUser())).rejects.toThrow();
68
+ });
69
+ });
70
+ describe('markAllAsRead', ()=>{
71
+ it('returns the count of notifications marked as read', async ()=>{
72
+ mockService.markAllAsRead.mockResolvedValue(3);
73
+ const result = await controller.markAllAsRead(buildMockUser());
74
+ expect(result.success).toBe(true);
75
+ expect(result.messageKey).toBe(NOTIFICATION_MESSAGES.MARK_ALL_READ_SUCCESS);
76
+ expect(result.messageVariables).toEqual({
77
+ count: 3
78
+ });
79
+ expect(result.message).toBe('3 notifications marked as read');
80
+ });
81
+ it('handles a user with zero unread notifications gracefully', async ()=>{
82
+ mockService.markAllAsRead.mockResolvedValue(0);
83
+ const result = await controller.markAllAsRead(buildMockUser());
84
+ expect(result.messageVariables).toEqual({
85
+ count: 0
86
+ });
87
+ expect(result.message).toBe('0 notifications marked as read');
88
+ });
89
+ });
90
+ describe('getUnreadCount', ()=>{
91
+ it('returns the unread count wrapped in a single response', async ()=>{
92
+ mockService.getUnreadCount.mockResolvedValue(5);
93
+ const result = await controller.getUnreadCount(buildMockUser());
94
+ expect(result.success).toBe(true);
95
+ expect(result.messageKey).toBe(NOTIFICATION_MESSAGES.UNREAD_COUNT_SUCCESS);
96
+ expect(result.data).toEqual({
97
+ count: 5
98
+ });
99
+ });
100
+ it('returns zero for a user with no unread notifications', async ()=>{
101
+ mockService.getUnreadCount.mockResolvedValue(0);
102
+ const result = await controller.getUnreadCount(buildMockUser());
103
+ expect(result.data).toEqual({
104
+ count: 0
105
+ });
106
+ });
107
+ });
108
+ describe('sendNotification', ()=>{
109
+ it('sends a notification via the helper service and returns the mapped response', async ()=>{
110
+ const notification = buildNotification({
111
+ id: 'notif-2'
112
+ });
113
+ mockHelper.send.mockResolvedValue(notification);
114
+ const result = await controller.sendNotification({
115
+ userId: 'user-uuid-1',
116
+ title: 'Hello',
117
+ message: 'World'
118
+ });
119
+ expect(mockHelper.send).toHaveBeenCalledWith({
120
+ userId: 'user-uuid-1',
121
+ title: 'Hello',
122
+ message: 'World',
123
+ type: undefined,
124
+ data: undefined,
125
+ companyId: undefined
126
+ });
127
+ expect(result.success).toBe(true);
128
+ expect(result.messageKey).toBe(NOTIFICATION_MESSAGES.SEND_SUCCESS);
129
+ expect(result.data?.id).toBe('notif-2');
130
+ });
131
+ it('propagates errors from the helper service', async ()=>{
132
+ mockHelper.send.mockRejectedValue(new Error('db down'));
133
+ await expect(controller.sendNotification({
134
+ userId: 'user-1',
135
+ title: 'X'
136
+ })).rejects.toThrow('db down');
137
+ });
138
+ });
139
+ describe('sendBulkNotification', ()=>{
140
+ it('sends notifications to multiple users and returns bulk meta', async ()=>{
141
+ const notifications = [
142
+ buildNotification({
143
+ id: 'n1',
144
+ userId: 'user-1'
145
+ }),
146
+ buildNotification({
147
+ id: 'n2',
148
+ userId: 'user-2'
149
+ })
150
+ ];
151
+ mockHelper.sendToMany.mockResolvedValue(notifications);
152
+ const result = await controller.sendBulkNotification({
153
+ userIds: [
154
+ 'user-1',
155
+ 'user-2'
156
+ ],
157
+ title: 'Broadcast'
158
+ });
159
+ expect(mockHelper.sendToMany).toHaveBeenCalledWith({
160
+ userIds: [
161
+ 'user-1',
162
+ 'user-2'
163
+ ],
164
+ title: 'Broadcast',
165
+ message: undefined,
166
+ type: undefined,
167
+ data: undefined,
168
+ companyId: undefined
169
+ });
170
+ expect(result.success).toBe(true);
171
+ expect(result.messageKey).toBe(NOTIFICATION_MESSAGES.BROADCAST_SUCCESS);
172
+ expect(result.data).toHaveLength(2);
173
+ expect(result.meta).toEqual({
174
+ count: 2,
175
+ total: 2
176
+ });
177
+ });
178
+ it('reports a mismatch between requested and sent counts in the meta', async ()=>{
179
+ mockHelper.sendToMany.mockResolvedValue([
180
+ buildNotification({
181
+ id: 'n1'
182
+ })
183
+ ]);
184
+ const result = await controller.sendBulkNotification({
185
+ userIds: [
186
+ 'user-1',
187
+ 'user-2',
188
+ 'user-3'
189
+ ],
190
+ title: 'Partial broadcast'
191
+ });
192
+ expect(result.meta).toEqual({
193
+ count: 1,
194
+ total: 3
195
+ });
196
+ });
197
+ it('handles an empty userIds bulk send without throwing', async ()=>{
198
+ mockHelper.sendToMany.mockResolvedValue([]);
199
+ const result = await controller.sendBulkNotification({
200
+ userIds: [],
201
+ title: 'Nobody'
202
+ });
203
+ expect(result.data).toEqual([]);
204
+ expect(result.meta).toEqual({
205
+ count: 0,
206
+ total: 0
207
+ });
208
+ });
209
+ });
210
+ });
@@ -0,0 +1,64 @@
1
+ import { notificationSwaggerConfig } from './notification-swagger.config';
2
+ describe('notificationSwaggerConfig', ()=>{
3
+ it('should default to company feature enabled and single-database mode when no config is given', ()=>{
4
+ const config = notificationSwaggerConfig();
5
+ expect(config.title).toBe('Notification API');
6
+ expect(config.path).toBe('api/docs/notification');
7
+ expect(config.bearerAuth).toBe(true);
8
+ expect(config.excludeSchemaProperties).toBeUndefined();
9
+ expect(config.description).not.toContain('Multi-Tenant Mode');
10
+ });
11
+ it('should exclude companyId schema properties across all notification DTOs when the company feature is disabled', ()=>{
12
+ const config = notificationSwaggerConfig({
13
+ enableCompanyFeature: false
14
+ });
15
+ expect(config.excludeSchemaProperties).toEqual([
16
+ {
17
+ schemaName: 'NotificationResponseDto',
18
+ properties: [
19
+ 'companyId'
20
+ ]
21
+ },
22
+ {
23
+ schemaName: 'CreateNotificationDto',
24
+ properties: [
25
+ 'companyId'
26
+ ]
27
+ },
28
+ {
29
+ schemaName: 'SendNotificationDto',
30
+ properties: [
31
+ 'companyId'
32
+ ]
33
+ },
34
+ {
35
+ schemaName: 'SendBulkNotificationDto',
36
+ properties: [
37
+ 'companyId'
38
+ ]
39
+ },
40
+ {
41
+ schemaName: 'UpdateNotificationDto',
42
+ properties: [
43
+ 'companyId'
44
+ ]
45
+ }
46
+ ]);
47
+ });
48
+ it('should not exclude any schema properties when the company feature is enabled', ()=>{
49
+ const config = notificationSwaggerConfig({
50
+ enableCompanyFeature: true
51
+ });
52
+ expect(config.excludeSchemaProperties).toBeUndefined();
53
+ });
54
+ it('should mention multi-tenant mode in the description only for multi-tenant database mode', ()=>{
55
+ const multiTenant = notificationSwaggerConfig({
56
+ databaseMode: 'multi-tenant'
57
+ });
58
+ const single = notificationSwaggerConfig({
59
+ databaseMode: 'single'
60
+ });
61
+ expect(multiTenant.description).toContain('Multi-Tenant Mode');
62
+ expect(single.description).not.toContain('Multi-Tenant Mode');
63
+ });
64
+ });
@@ -0,0 +1,9 @@
1
+ import { getNotificationEntitiesByConfig, NotificationCoreEntities, NotificationCompanyEntities } from './index';
2
+ describe('getNotificationEntitiesByConfig', ()=>{
3
+ it('returns the core entities when the company feature is disabled', ()=>{
4
+ expect(getNotificationEntitiesByConfig(false)).toBe(NotificationCoreEntities);
5
+ });
6
+ it('returns the company-scoped entities when the company feature is enabled', ()=>{
7
+ expect(getNotificationEntitiesByConfig(true)).toBe(NotificationCompanyEntities);
8
+ });
9
+ });
@@ -0,0 +1,355 @@
1
+ import { NotificationGateway } from './notification.gateway';
2
+ function createMockServer() {
3
+ const room = {
4
+ emit: jest.fn()
5
+ };
6
+ return {
7
+ to: jest.fn().mockReturnValue(room),
8
+ emit: jest.fn()
9
+ };
10
+ }
11
+ function createMockSocket(overrides = {}) {
12
+ return {
13
+ id: 'socket-id-1',
14
+ handshake: {
15
+ auth: {},
16
+ query: {},
17
+ headers: {}
18
+ },
19
+ data: {},
20
+ join: jest.fn(),
21
+ disconnect: jest.fn(),
22
+ ...overrides
23
+ };
24
+ }
25
+ describe('NotificationGateway', ()=>{
26
+ let gateway;
27
+ let mockConfigService;
28
+ let mockJwtService;
29
+ let mockServer;
30
+ beforeEach(()=>{
31
+ mockConfigService = {
32
+ getJwtSecret: jest.fn().mockReturnValue('jwt-secret')
33
+ };
34
+ mockJwtService = {
35
+ verify: jest.fn()
36
+ };
37
+ gateway = new NotificationGateway(mockConfigService, mockJwtService);
38
+ mockServer = createMockServer();
39
+ gateway.server = mockServer;
40
+ });
41
+ describe('afterInit', ()=>{
42
+ it('does not throw', ()=>{
43
+ expect(()=>gateway.afterInit()).not.toThrow();
44
+ });
45
+ });
46
+ describe('handleConnection', ()=>{
47
+ it('disconnects the client when no token is present anywhere', async ()=>{
48
+ const client = createMockSocket();
49
+ await gateway.handleConnection(client);
50
+ expect(client.disconnect).toHaveBeenCalled();
51
+ expect(mockJwtService.verify).not.toHaveBeenCalled();
52
+ });
53
+ it('accepts a token from handshake.auth and joins the user + company rooms', async ()=>{
54
+ const client = createMockSocket({
55
+ handshake: {
56
+ auth: {
57
+ token: 'auth-token'
58
+ },
59
+ query: {},
60
+ headers: {}
61
+ }
62
+ });
63
+ mockJwtService.verify.mockReturnValue({
64
+ id: 'user-1',
65
+ companyId: 'company-1'
66
+ });
67
+ await gateway.handleConnection(client);
68
+ expect(mockJwtService.verify).toHaveBeenCalledWith('auth-token', {
69
+ secret: 'jwt-secret',
70
+ algorithms: [
71
+ 'HS256'
72
+ ]
73
+ });
74
+ expect(client.data.userId).toBe('user-1');
75
+ expect(client.data.companyId).toBe('company-1');
76
+ expect(client.join).toHaveBeenCalledWith('user:user-1');
77
+ expect(client.join).toHaveBeenCalledWith('company:company-1');
78
+ expect(client.join).toHaveBeenCalledWith('user:user-1:company:company-1');
79
+ expect(client.disconnect).not.toHaveBeenCalled();
80
+ expect(gateway.isUserOnline('user-1')).toBe(true);
81
+ });
82
+ it('accepts a token from the query string', async ()=>{
83
+ const client = createMockSocket({
84
+ handshake: {
85
+ auth: {},
86
+ query: {
87
+ token: 'query-token'
88
+ },
89
+ headers: {}
90
+ }
91
+ });
92
+ mockJwtService.verify.mockReturnValue({
93
+ id: 'user-2'
94
+ });
95
+ await gateway.handleConnection(client);
96
+ expect(mockJwtService.verify).toHaveBeenCalledWith('query-token', expect.any(Object));
97
+ expect(client.join).toHaveBeenCalledWith('user:user-2');
98
+ });
99
+ it('accepts a token from the Authorization header', async ()=>{
100
+ const client = createMockSocket({
101
+ handshake: {
102
+ auth: {},
103
+ query: {},
104
+ headers: {
105
+ authorization: 'Bearer header-token'
106
+ }
107
+ }
108
+ });
109
+ mockJwtService.verify.mockReturnValue({
110
+ id: 'user-3'
111
+ });
112
+ await gateway.handleConnection(client);
113
+ expect(mockJwtService.verify).toHaveBeenCalledWith('header-token', expect.any(Object));
114
+ expect(client.join).toHaveBeenCalledWith('user:user-3');
115
+ });
116
+ it('ignores a malformed Authorization header without the Bearer prefix', async ()=>{
117
+ const client = createMockSocket({
118
+ handshake: {
119
+ auth: {},
120
+ query: {},
121
+ headers: {
122
+ authorization: 'Basic abc123'
123
+ }
124
+ }
125
+ });
126
+ await gateway.handleConnection(client);
127
+ expect(client.disconnect).toHaveBeenCalled();
128
+ expect(mockJwtService.verify).not.toHaveBeenCalled();
129
+ });
130
+ it('joins only the personal room when the token payload has no companyId', async ()=>{
131
+ const client = createMockSocket({
132
+ handshake: {
133
+ auth: {
134
+ token: 't'
135
+ },
136
+ query: {},
137
+ headers: {}
138
+ }
139
+ });
140
+ mockJwtService.verify.mockReturnValue({
141
+ id: 'user-4'
142
+ });
143
+ await gateway.handleConnection(client);
144
+ expect(client.join).toHaveBeenCalledWith('user:user-4');
145
+ expect(client.join).toHaveBeenCalledTimes(1);
146
+ });
147
+ it('disconnects the client when the JWT secret is not configured', async ()=>{
148
+ mockConfigService.getJwtSecret.mockReturnValue(undefined);
149
+ const client = createMockSocket({
150
+ handshake: {
151
+ auth: {
152
+ token: 't'
153
+ },
154
+ query: {},
155
+ headers: {}
156
+ }
157
+ });
158
+ await gateway.handleConnection(client);
159
+ expect(client.disconnect).toHaveBeenCalled();
160
+ expect(mockJwtService.verify).not.toHaveBeenCalled();
161
+ });
162
+ it('disconnects the client when token verification throws (invalid/expired token)', async ()=>{
163
+ const client = createMockSocket({
164
+ handshake: {
165
+ auth: {
166
+ token: 'bad-token'
167
+ },
168
+ query: {},
169
+ headers: {}
170
+ }
171
+ });
172
+ mockJwtService.verify.mockImplementation(()=>{
173
+ throw new Error('jwt expired');
174
+ });
175
+ await gateway.handleConnection(client);
176
+ expect(client.disconnect).toHaveBeenCalled();
177
+ expect(gateway.isUserOnline('user-1')).toBe(false);
178
+ });
179
+ it('tracks multiple sockets for the same user', async ()=>{
180
+ mockJwtService.verify.mockReturnValue({
181
+ id: 'user-5'
182
+ });
183
+ const clientA = createMockSocket({
184
+ id: 'socket-a',
185
+ handshake: {
186
+ auth: {
187
+ token: 't'
188
+ },
189
+ query: {},
190
+ headers: {}
191
+ }
192
+ });
193
+ const clientB = createMockSocket({
194
+ id: 'socket-b',
195
+ handshake: {
196
+ auth: {
197
+ token: 't'
198
+ },
199
+ query: {},
200
+ headers: {}
201
+ }
202
+ });
203
+ await gateway.handleConnection(clientA);
204
+ await gateway.handleConnection(clientB);
205
+ expect(gateway.isUserOnline('user-5')).toBe(true);
206
+ expect(gateway.getOnlineUserCount()).toBe(1);
207
+ });
208
+ it('disconnects the client when an unexpected error occurs during connection handling', async ()=>{
209
+ const client = createMockSocket({
210
+ handshake: {
211
+ auth: {
212
+ token: 't'
213
+ },
214
+ query: {},
215
+ headers: {}
216
+ }
217
+ });
218
+ mockJwtService.verify.mockReturnValue({
219
+ id: 'user-6'
220
+ });
221
+ client.join = jest.fn().mockImplementation(()=>{
222
+ throw new Error('join failed');
223
+ });
224
+ await gateway.handleConnection(client);
225
+ expect(client.disconnect).toHaveBeenCalled();
226
+ });
227
+ });
228
+ describe('handleDisconnect', ()=>{
229
+ it('removes the socket and marks the user offline when it was their only connection', async ()=>{
230
+ mockJwtService.verify.mockReturnValue({
231
+ id: 'user-1'
232
+ });
233
+ const client = createMockSocket({
234
+ handshake: {
235
+ auth: {
236
+ token: 't'
237
+ },
238
+ query: {},
239
+ headers: {}
240
+ }
241
+ });
242
+ await gateway.handleConnection(client);
243
+ expect(gateway.isUserOnline('user-1')).toBe(true);
244
+ gateway.handleDisconnect(client);
245
+ expect(gateway.isUserOnline('user-1')).toBe(false);
246
+ expect(gateway.getOnlineUserCount()).toBe(0);
247
+ });
248
+ it('keeps the user online when they still have another connected socket', async ()=>{
249
+ mockJwtService.verify.mockReturnValue({
250
+ id: 'user-1'
251
+ });
252
+ const clientA = createMockSocket({
253
+ id: 'socket-a',
254
+ handshake: {
255
+ auth: {
256
+ token: 't'
257
+ },
258
+ query: {},
259
+ headers: {}
260
+ }
261
+ });
262
+ const clientB = createMockSocket({
263
+ id: 'socket-b',
264
+ handshake: {
265
+ auth: {
266
+ token: 't'
267
+ },
268
+ query: {},
269
+ headers: {}
270
+ }
271
+ });
272
+ await gateway.handleConnection(clientA);
273
+ await gateway.handleConnection(clientB);
274
+ gateway.handleDisconnect(clientA);
275
+ expect(gateway.isUserOnline('user-1')).toBe(true);
276
+ });
277
+ it('does nothing when the client never authenticated (no userId on socket data)', ()=>{
278
+ const client = createMockSocket();
279
+ expect(()=>gateway.handleDisconnect(client)).not.toThrow();
280
+ expect(gateway.getOnlineUserCount()).toBe(0);
281
+ });
282
+ it('is a no-op when disconnecting an unknown user id', ()=>{
283
+ const client = createMockSocket({
284
+ data: {
285
+ userId: 'ghost-user'
286
+ }
287
+ });
288
+ expect(()=>gateway.handleDisconnect(client)).not.toThrow();
289
+ expect(gateway.isUserOnline('ghost-user')).toBe(false);
290
+ });
291
+ });
292
+ describe('emitToUser', ()=>{
293
+ it('emits the event to the user-scoped room', ()=>{
294
+ gateway.emitToUser('user-1', 'notification:new', {
295
+ title: 'hi'
296
+ });
297
+ expect(mockServer.to).toHaveBeenCalledWith('user:user-1');
298
+ expect(mockServer.to('user:user-1').emit).toHaveBeenCalledWith('notification:new', {
299
+ title: 'hi'
300
+ });
301
+ });
302
+ it('does not throw when the server has not been initialized yet', ()=>{
303
+ const freshGateway = new NotificationGateway(mockConfigService, mockJwtService);
304
+ expect(()=>freshGateway.emitToUser('user-1', 'notification:new', {})).not.toThrow();
305
+ });
306
+ });
307
+ describe('emitToUserInCompany', ()=>{
308
+ it('emits to the combined user+company room when companyId is provided', ()=>{
309
+ gateway.emitToUserInCompany('user-1', 'company-1', 'notification:new', {
310
+ a: 1
311
+ });
312
+ expect(mockServer.to).toHaveBeenCalledWith('user:user-1:company:company-1');
313
+ });
314
+ it('falls back to emitToUser when companyId is null', ()=>{
315
+ gateway.emitToUserInCompany('user-1', null, 'notification:new', {
316
+ a: 1
317
+ });
318
+ expect(mockServer.to).toHaveBeenCalledWith('user:user-1');
319
+ });
320
+ it('falls back to emitToUser when companyId is undefined', ()=>{
321
+ gateway.emitToUserInCompany('user-1', undefined, 'notification:new', {
322
+ a: 1
323
+ });
324
+ expect(mockServer.to).toHaveBeenCalledWith('user:user-1');
325
+ });
326
+ it('does not throw when the server has not been initialized yet', ()=>{
327
+ const freshGateway = new NotificationGateway(mockConfigService, mockJwtService);
328
+ expect(()=>freshGateway.emitToUserInCompany('user-1', 'company-1', 'notification:new', {})).not.toThrow();
329
+ });
330
+ });
331
+ describe('emitToCompany', ()=>{
332
+ it('emits the event to the company-scoped room', ()=>{
333
+ gateway.emitToCompany('company-1', 'notification:new', {
334
+ b: 2
335
+ });
336
+ expect(mockServer.to).toHaveBeenCalledWith('company:company-1');
337
+ });
338
+ });
339
+ describe('broadcast', ()=>{
340
+ it('emits the event to all connected sockets', ()=>{
341
+ gateway.broadcast('server:announcement', {
342
+ text: 'hi all'
343
+ });
344
+ expect(mockServer.emit).toHaveBeenCalledWith('server:announcement', {
345
+ text: 'hi all'
346
+ });
347
+ });
348
+ });
349
+ describe('isUserOnline / getOnlineUserCount', ()=>{
350
+ it('reports zero online users when nobody has connected', ()=>{
351
+ expect(gateway.isUserOnline('nobody')).toBe(false);
352
+ expect(gateway.getOnlineUserCount()).toBe(0);
353
+ });
354
+ });
355
+ });