@flusys/nestjs-notification 6.0.1 → 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,486 @@
1
+ import { Test } from '@nestjs/testing';
2
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
3
+ import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
5
+ import { NotificationService } from './notification.service';
6
+ import { NotificationConfigService } from './notification-config.service';
7
+ import { NotificationDataSourceProvider } from './notification-datasource.provider';
8
+ import { NotificationGateway } from '../gateways';
9
+ import { Notification, NotificationWithCompany } from '../entities';
10
+ function buildNotification(overrides = {}) {
11
+ return {
12
+ id: 'notif-uuid-1',
13
+ userId: 'user-uuid-1',
14
+ type: 'info',
15
+ title: 'Title',
16
+ message: 'Message',
17
+ data: null,
18
+ isRead: false,
19
+ readAt: null,
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ deletedAt: null,
23
+ createdById: null,
24
+ updatedById: null,
25
+ deletedById: null,
26
+ ...overrides
27
+ };
28
+ }
29
+ function createMockQuery() {
30
+ return {
31
+ andWhere: jest.fn().mockReturnThis(),
32
+ addSelect: jest.fn().mockReturnThis(),
33
+ orderBy: jest.fn().mockReturnThis()
34
+ };
35
+ }
36
+ describe('NotificationService', ()=>{
37
+ let service;
38
+ let mockRepo;
39
+ let mockConfig;
40
+ let mockGateway;
41
+ async function buildService(withGateway = true) {
42
+ mockRepo = createMockRepository();
43
+ const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
44
+ mockConfig = {
45
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
46
+ };
47
+ mockGateway = {
48
+ emitToUser: jest.fn(),
49
+ emitToUserInCompany: jest.fn()
50
+ };
51
+ const providers = [
52
+ NotificationService,
53
+ {
54
+ provide: 'CACHE_INSTANCE',
55
+ useValue: {}
56
+ },
57
+ {
58
+ provide: UtilsService,
59
+ useValue: {
60
+ clearCache: jest.fn().mockResolvedValue(undefined)
61
+ }
62
+ },
63
+ {
64
+ provide: NotificationConfigService,
65
+ useValue: mockConfig
66
+ },
67
+ {
68
+ provide: NotificationDataSourceProvider,
69
+ useValue: mockDataSourceProvider
70
+ }
71
+ ];
72
+ if (withGateway) {
73
+ providers.push({
74
+ provide: NotificationGateway,
75
+ useValue: mockGateway
76
+ });
77
+ }
78
+ const module = await Test.createTestingModule({
79
+ providers
80
+ }).compile();
81
+ const resolved = await module.resolve(NotificationService);
82
+ resolved.repository = mockRepo;
83
+ return resolved;
84
+ }
85
+ beforeEach(async ()=>{
86
+ service = await buildService();
87
+ });
88
+ describe('resolveEntity', ()=>{
89
+ it('returns Notification when the company feature is disabled', ()=>{
90
+ expect(service.resolveEntity()).toBe(Notification);
91
+ });
92
+ it('returns NotificationWithCompany when the company feature is enabled', ()=>{
93
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
94
+ expect(service.resolveEntity()).toBe(NotificationWithCompany);
95
+ });
96
+ });
97
+ describe('convertSingleDtoToEntity', ()=>{
98
+ it('builds a new entity for create (no id present)', async ()=>{
99
+ const user = buildMockUser();
100
+ const dto = {
101
+ userId: user.id,
102
+ title: 'Hello',
103
+ type: 'success'
104
+ };
105
+ const entity = await service.convertSingleDtoToEntity(dto, user);
106
+ expect(entity).toMatchObject({
107
+ userId: user.id,
108
+ title: 'Hello',
109
+ type: 'success',
110
+ isRead: false
111
+ });
112
+ });
113
+ it('defaults type to "info" when not provided', async ()=>{
114
+ const user = buildMockUser();
115
+ const entity = await service.convertSingleDtoToEntity({
116
+ userId: user.id,
117
+ title: 'Hi'
118
+ }, user);
119
+ expect(entity.type).toBe('info');
120
+ });
121
+ it('merges into the existing entity when updating by id', async ()=>{
122
+ const existing = buildNotification({
123
+ id: 'notif-1',
124
+ title: 'Old title'
125
+ });
126
+ mockRepo.findOne.mockResolvedValue(existing);
127
+ const user = buildMockUser();
128
+ const entity = await service.convertSingleDtoToEntity({
129
+ id: 'notif-1',
130
+ title: 'New title'
131
+ }, user);
132
+ expect(mockRepo.findOne).toHaveBeenCalledWith({
133
+ where: {
134
+ id: 'notif-1'
135
+ }
136
+ });
137
+ expect(entity.title).toBe('New title');
138
+ expect(entity.isRead).toBe(false);
139
+ });
140
+ it('throws NotFoundException when updating an id that does not exist', async ()=>{
141
+ mockRepo.findOne.mockResolvedValue(null);
142
+ await expect(service.convertSingleDtoToEntity({
143
+ id: 'missing-id',
144
+ title: 'X'
145
+ }, buildMockUser())).rejects.toThrow();
146
+ });
147
+ it('includes companyId from the dto when the company feature is enabled', async ()=>{
148
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
149
+ const user = buildMockUser({
150
+ companyId: 'company-user'
151
+ });
152
+ const entity = await service.convertSingleDtoToEntity({
153
+ userId: user.id,
154
+ title: 'Hi',
155
+ companyId: 'company-dto'
156
+ }, user);
157
+ expect(entity.companyId).toBe('company-dto');
158
+ });
159
+ it('falls back to the user companyId when the dto has none and the company feature is enabled', async ()=>{
160
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
161
+ const user = buildMockUser({
162
+ companyId: 'company-user'
163
+ });
164
+ const entity = await service.convertSingleDtoToEntity({
165
+ userId: user.id,
166
+ title: 'Hi'
167
+ }, user);
168
+ expect(entity.companyId).toBe('company-user');
169
+ });
170
+ it('does not set companyId when the company feature is disabled', async ()=>{
171
+ const user = buildMockUser({
172
+ companyId: 'company-user'
173
+ });
174
+ const entity = await service.convertSingleDtoToEntity({
175
+ userId: user.id,
176
+ title: 'Hi',
177
+ companyId: 'company-dto'
178
+ }, user);
179
+ expect(entity.companyId).toBeUndefined();
180
+ });
181
+ });
182
+ describe('getFilterQuery', ()=>{
183
+ it('adds an isRead filter when provided as boolean', async ()=>{
184
+ const query = createMockQuery();
185
+ await service.getFilterQuery(query, {
186
+ isRead: true
187
+ }, buildMockUser());
188
+ expect(query.andWhere).toHaveBeenCalledWith('notification.isRead = :isRead', {
189
+ isRead: true
190
+ });
191
+ });
192
+ it('adds a type filter when provided', async ()=>{
193
+ const query = createMockQuery();
194
+ await service.getFilterQuery(query, {
195
+ type: 'success'
196
+ }, buildMockUser());
197
+ expect(query.andWhere).toHaveBeenCalledWith('notification.type = :type', {
198
+ type: 'success'
199
+ });
200
+ });
201
+ it('adds a userId filter when provided', async ()=>{
202
+ const query = createMockQuery();
203
+ await service.getFilterQuery(query, {
204
+ userId: 'user-9'
205
+ }, buildMockUser());
206
+ expect(query.andWhere).toHaveBeenCalledWith('notification.userId = :userId', {
207
+ userId: 'user-9'
208
+ });
209
+ });
210
+ it('ignores unrecognized filter keys', async ()=>{
211
+ const query = createMockQuery();
212
+ await service.getFilterQuery(query, {
213
+ unknownField: 'x'
214
+ }, buildMockUser());
215
+ expect(query.andWhere).not.toHaveBeenCalled();
216
+ });
217
+ it('ignores a non-boolean isRead value', async ()=>{
218
+ const query = createMockQuery();
219
+ await service.getFilterQuery(query, {
220
+ isRead: 'true'
221
+ }, buildMockUser());
222
+ expect(query.andWhere).not.toHaveBeenCalled();
223
+ });
224
+ });
225
+ describe('getSelectQuery', ()=>{
226
+ it('always selects createdAt and updatedAt in addition to requested fields', async ()=>{
227
+ const query = createMockQuery();
228
+ await service.getSelectQuery(query, buildMockUser(), [
229
+ 'title',
230
+ 'message'
231
+ ]);
232
+ expect(query.addSelect).toHaveBeenCalledWith([
233
+ 'notification.title',
234
+ 'notification.message',
235
+ 'notification.createdAt',
236
+ 'notification.updatedAt'
237
+ ]);
238
+ });
239
+ it('selects only createdAt/updatedAt when no fields are requested', async ()=>{
240
+ const query = createMockQuery();
241
+ await service.getSelectQuery(query, buildMockUser(), undefined);
242
+ expect(query.addSelect).toHaveBeenCalledWith([
243
+ 'notification.createdAt',
244
+ 'notification.updatedAt'
245
+ ]);
246
+ });
247
+ it('deduplicates fields already included in the always-selected set', async ()=>{
248
+ const query = createMockQuery();
249
+ await service.getSelectQuery(query, buildMockUser(), [
250
+ 'createdAt'
251
+ ]);
252
+ expect(query.addSelect).toHaveBeenCalledWith([
253
+ 'notification.createdAt',
254
+ 'notification.updatedAt'
255
+ ]);
256
+ });
257
+ });
258
+ describe('getExtraManipulateQuery', ()=>{
259
+ it('scopes the query to the current user', async ()=>{
260
+ const query = createMockQuery();
261
+ const user = buildMockUser();
262
+ await service.getExtraManipulateQuery(query, {}, user);
263
+ expect(query.andWhere).toHaveBeenCalledWith('notification.userId = :currentUserId', {
264
+ currentUserId: user.id
265
+ });
266
+ });
267
+ it('applies the company filter when the company feature is enabled', async ()=>{
268
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
269
+ const query = createMockQuery();
270
+ const user = buildMockUser({
271
+ companyId: 'company-1'
272
+ });
273
+ await service.getExtraManipulateQuery(query, {}, user);
274
+ expect(query.andWhere).toHaveBeenCalledWith('notification.companyId = :companyId', {
275
+ companyId: 'company-1'
276
+ });
277
+ });
278
+ it('defaults to ordering by createdAt DESC when no sort is provided', async ()=>{
279
+ const query = createMockQuery();
280
+ await service.getExtraManipulateQuery(query, {}, buildMockUser());
281
+ expect(query.orderBy).toHaveBeenCalledWith('notification.createdAt', 'DESC');
282
+ });
283
+ it('does not apply a default sort when the caller already specified one', async ()=>{
284
+ const query = createMockQuery();
285
+ await service.getExtraManipulateQuery(query, {
286
+ sort: {
287
+ title: 'ASC'
288
+ }
289
+ }, buildMockUser());
290
+ expect(query.orderBy).not.toHaveBeenCalled();
291
+ });
292
+ it('does not scope by user when no user is provided', async ()=>{
293
+ const query = createMockQuery();
294
+ await service.getExtraManipulateQuery(query, {}, null);
295
+ expect(query.andWhere).not.toHaveBeenCalledWith(expect.stringContaining('currentUserId'), expect.anything());
296
+ });
297
+ });
298
+ describe('afterInsertOperation', ()=>{
299
+ it('emits a notification:new event for each inserted entity', async ()=>{
300
+ const entities = [
301
+ buildNotification({
302
+ id: 'n1'
303
+ }),
304
+ buildNotification({
305
+ id: 'n2'
306
+ })
307
+ ];
308
+ await service.afterInsertOperation(entities, {}, buildMockUser(), {});
309
+ expect(mockGateway.emitToUser).toHaveBeenCalledTimes(2);
310
+ expect(mockGateway.emitToUser).toHaveBeenCalledWith('user-uuid-1', 'notification:new', expect.objectContaining({
311
+ id: 'n1'
312
+ }));
313
+ });
314
+ it('uses emitToUserInCompany when the entity has a companyId and the company feature is enabled', async ()=>{
315
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
316
+ const entities = [
317
+ buildNotification({
318
+ id: 'n1',
319
+ companyId: 'company-1'
320
+ })
321
+ ];
322
+ await service.afterInsertOperation(entities, {}, buildMockUser(), {});
323
+ expect(mockGateway.emitToUserInCompany).toHaveBeenCalledWith('user-uuid-1', 'company-1', 'notification:new', expect.any(Object));
324
+ });
325
+ });
326
+ describe('markAsRead', ()=>{
327
+ it('marks a matching notification as read and returns it', async ()=>{
328
+ const user = buildMockUser();
329
+ const notification = buildNotification({
330
+ id: 'notif-1',
331
+ userId: user.id,
332
+ isRead: false
333
+ });
334
+ mockRepo.findOne.mockResolvedValue(notification);
335
+ mockRepo.save.mockResolvedValue({
336
+ ...notification,
337
+ isRead: true
338
+ });
339
+ const result = await service.markAsRead('notif-1', user);
340
+ expect(mockRepo.findOne).toHaveBeenCalledWith({
341
+ where: {
342
+ id: 'notif-1',
343
+ userId: user.id
344
+ }
345
+ });
346
+ expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({
347
+ isRead: true,
348
+ readAt: expect.any(Date)
349
+ }));
350
+ expect(result.isRead).toBe(true);
351
+ expect(mockGateway.emitToUser).toHaveBeenCalledWith(user.id, 'notification:read', {
352
+ id: 'notif-1'
353
+ });
354
+ });
355
+ it('throws NotFoundException when the notification does not belong to the user or does not exist', async ()=>{
356
+ mockRepo.findOne.mockResolvedValue(null);
357
+ await expect(service.markAsRead('missing-id', buildMockUser())).rejects.toThrow();
358
+ expect(mockRepo.save).not.toHaveBeenCalled();
359
+ });
360
+ it('emits to the company-scoped room when the notification has a companyId', async ()=>{
361
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
362
+ const user = buildMockUser({
363
+ companyId: 'company-1'
364
+ });
365
+ const notification = buildNotification({
366
+ id: 'notif-1',
367
+ userId: user.id,
368
+ companyId: 'company-1'
369
+ });
370
+ mockRepo.findOne.mockResolvedValue(notification);
371
+ mockRepo.save.mockResolvedValue({
372
+ ...notification,
373
+ isRead: true
374
+ });
375
+ await service.markAsRead('notif-1', user);
376
+ expect(mockGateway.emitToUserInCompany).toHaveBeenCalledWith(user.id, 'company-1', 'notification:read', {
377
+ id: 'notif-1'
378
+ });
379
+ });
380
+ });
381
+ describe('markAllAsRead', ()=>{
382
+ it('marks all unread notifications as read for the user and returns the affected count', async ()=>{
383
+ const user = buildMockUser();
384
+ mockRepo.update.mockResolvedValue({
385
+ affected: 4,
386
+ raw: {},
387
+ generatedMaps: []
388
+ });
389
+ const count = await service.markAllAsRead(user);
390
+ expect(mockRepo.update).toHaveBeenCalledWith({
391
+ userId: user.id,
392
+ isRead: false
393
+ }, {
394
+ isRead: true,
395
+ readAt: expect.any(Date)
396
+ });
397
+ expect(count).toBe(4);
398
+ expect(mockGateway.emitToUser).toHaveBeenCalledWith(user.id, 'notification:read-all', {});
399
+ });
400
+ it('returns 0 when there is nothing to update (zero unread notifications)', async ()=>{
401
+ mockRepo.update.mockResolvedValue({
402
+ affected: 0,
403
+ raw: {},
404
+ generatedMaps: []
405
+ });
406
+ const count = await service.markAllAsRead(buildMockUser());
407
+ expect(count).toBe(0);
408
+ });
409
+ it('returns 0 when affected is undefined/null', async ()=>{
410
+ mockRepo.update.mockResolvedValue({
411
+ affected: undefined,
412
+ raw: {},
413
+ generatedMaps: []
414
+ });
415
+ const count = await service.markAllAsRead(buildMockUser());
416
+ expect(count).toBe(0);
417
+ });
418
+ it('scopes the update by companyId when the company feature is enabled', async ()=>{
419
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
420
+ const user = buildMockUser({
421
+ companyId: 'company-1'
422
+ });
423
+ mockRepo.update.mockResolvedValue({
424
+ affected: 1,
425
+ raw: {},
426
+ generatedMaps: []
427
+ });
428
+ await service.markAllAsRead(user);
429
+ expect(mockRepo.update).toHaveBeenCalledWith({
430
+ userId: user.id,
431
+ isRead: false,
432
+ companyId: 'company-1'
433
+ }, expect.any(Object));
434
+ });
435
+ });
436
+ describe('getUnreadCount', ()=>{
437
+ it('returns the unread count for the user', async ()=>{
438
+ const user = buildMockUser();
439
+ mockRepo.count.mockResolvedValue(7);
440
+ const count = await service.getUnreadCount(user);
441
+ expect(mockRepo.count).toHaveBeenCalledWith({
442
+ where: {
443
+ userId: user.id,
444
+ isRead: false
445
+ }
446
+ });
447
+ expect(count).toBe(7);
448
+ });
449
+ it('returns 0 for a user with no unread notifications', async ()=>{
450
+ mockRepo.count.mockResolvedValue(0);
451
+ const count = await service.getUnreadCount(buildMockUser());
452
+ expect(count).toBe(0);
453
+ });
454
+ it('scopes the count by companyId when the company feature is enabled', async ()=>{
455
+ mockConfig.isCompanyFeatureEnabled.mockReturnValue(true);
456
+ const user = buildMockUser({
457
+ companyId: 'company-1'
458
+ });
459
+ mockRepo.count.mockResolvedValue(2);
460
+ await service.getUnreadCount(user);
461
+ expect(mockRepo.count).toHaveBeenCalledWith({
462
+ where: {
463
+ userId: user.id,
464
+ isRead: false,
465
+ companyId: 'company-1'
466
+ }
467
+ });
468
+ });
469
+ });
470
+ describe('without a bound gateway', ()=>{
471
+ it('does not throw when marking as read and no gateway is registered', async ()=>{
472
+ const noGatewayService = await buildService(false);
473
+ const user = buildMockUser();
474
+ const notification = buildNotification({
475
+ id: 'notif-1',
476
+ userId: user.id
477
+ });
478
+ mockRepo.findOne.mockResolvedValue(notification);
479
+ mockRepo.save.mockResolvedValue({
480
+ ...notification,
481
+ isRead: true
482
+ });
483
+ await expect(noGatewayService.markAsRead('notif-1', user)).resolves.toBeDefined();
484
+ });
485
+ });
486
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-notification",
3
- "version": "6.0.1",
3
+ "version": "6.1.0",
4
4
  "description": "Notification module with real-time Socket.IO support",
5
5
  "main": "cjs/index.js",
6
6
  "module": "fesm/index.js",
@@ -93,7 +93,7 @@
93
93
  "express": "^5.0.0"
94
94
  },
95
95
  "dependencies": {
96
- "@flusys/nestjs-core": "6.0.1",
97
- "@flusys/nestjs-shared": "6.0.1"
96
+ "@flusys/nestjs-core": "6.1.0",
97
+ "@flusys/nestjs-shared": "6.1.0"
98
98
  }
99
99
  }