@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.
- package/cjs/adapters/notification.adapter.spec.js +73 -0
- package/cjs/controllers/notification.controller.spec.js +214 -0
- package/cjs/docs/notification-swagger.config.spec.js +68 -0
- package/cjs/entities/index.spec.js +13 -0
- package/cjs/gateways/notification.gateway.spec.js +359 -0
- package/cjs/modules/notification.module.spec.js +152 -0
- package/cjs/services/notification-config.service.spec.js +113 -0
- package/cjs/services/notification-datasource.provider.spec.js +136 -0
- package/cjs/services/notification-helper.service.spec.js +306 -0
- package/cjs/services/notification.service.spec.js +490 -0
- package/fesm/adapters/notification.adapter.spec.js +69 -0
- package/fesm/controllers/notification.controller.spec.js +210 -0
- package/fesm/docs/notification-swagger.config.spec.js +64 -0
- package/fesm/entities/index.spec.js +9 -0
- package/fesm/gateways/notification.gateway.spec.js +355 -0
- package/fesm/modules/notification.module.spec.js +148 -0
- package/fesm/services/notification-config.service.spec.js +109 -0
- package/fesm/services/notification-datasource.provider.spec.js +132 -0
- package/fesm/services/notification-helper.service.spec.js +302 -0
- package/fesm/services/notification.service.spec.js +486 -0
- package/package.json +3 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
import { NOTIFICATION_ADAPTER } from '@flusys/nestjs-shared/interfaces';
|
|
15
|
+
import { NotificationModule } from './notification.module';
|
|
16
|
+
import { NOTIFICATION_MODULE_OPTIONS } from '../config/notification.constants';
|
|
17
|
+
import { NotificationController } from '../controllers';
|
|
18
|
+
import { NotificationGateway } from '../gateways';
|
|
19
|
+
import { NotificationAdapter } from '../adapters';
|
|
20
|
+
function providerTokens(providers) {
|
|
21
|
+
return providers.map((p)=>typeof p === 'function' ? p : p.provide);
|
|
22
|
+
}
|
|
23
|
+
describe('NotificationModule', ()=>{
|
|
24
|
+
describe('forRoot', ()=>{
|
|
25
|
+
it('should default to a non-global module with the notification controller registered', ()=>{
|
|
26
|
+
const dynamicModule = NotificationModule.forRoot({});
|
|
27
|
+
expect(dynamicModule.module).toBe(NotificationModule);
|
|
28
|
+
expect(dynamicModule.global).toBe(false);
|
|
29
|
+
expect(dynamicModule.controllers).toEqual([
|
|
30
|
+
NotificationController
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
it('should respect global: true', ()=>{
|
|
34
|
+
expect(NotificationModule.forRoot({
|
|
35
|
+
global: true
|
|
36
|
+
}).global).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('should register no controllers when includeController is false', ()=>{
|
|
39
|
+
expect(NotificationModule.forRoot({
|
|
40
|
+
includeController: false
|
|
41
|
+
}).controllers).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
it('should register the NOTIFICATION_ADAPTER provider backed by NotificationAdapter', ()=>{
|
|
44
|
+
const dynamicModule = NotificationModule.forRoot({});
|
|
45
|
+
const adapterProvider = dynamicModule.providers.find((p)=>p.provide === NOTIFICATION_ADAPTER);
|
|
46
|
+
expect(adapterProvider.useClass).toBe(NotificationAdapter);
|
|
47
|
+
expect(providerTokens(dynamicModule.providers)).toContain(NotificationGateway);
|
|
48
|
+
});
|
|
49
|
+
it('should export NotificationGateway and NOTIFICATION_ADAPTER alongside the base services', ()=>{
|
|
50
|
+
const dynamicModule = NotificationModule.forRoot({});
|
|
51
|
+
expect(dynamicModule.exports).toEqual(expect.arrayContaining([
|
|
52
|
+
NotificationGateway,
|
|
53
|
+
NOTIFICATION_ADAPTER
|
|
54
|
+
]));
|
|
55
|
+
});
|
|
56
|
+
it('should register a NOTIFICATION_MODULE_OPTIONS provider carrying the given options', ()=>{
|
|
57
|
+
const options = {
|
|
58
|
+
bootstrapAppConfig: {
|
|
59
|
+
enableCompanyFeature: true
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const dynamicModule = NotificationModule.forRoot(options);
|
|
63
|
+
const provider = dynamicModule.providers.find((p)=>p.provide === NOTIFICATION_MODULE_OPTIONS);
|
|
64
|
+
expect(provider.useValue).toBe(options);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('forRootAsync', ()=>{
|
|
68
|
+
it('should forward external imports alongside CacheModule/UtilsModule', ()=>{
|
|
69
|
+
let FakeImportedModule = class FakeImportedModule {
|
|
70
|
+
};
|
|
71
|
+
const dynamicModule = NotificationModule.forRootAsync({
|
|
72
|
+
imports: [
|
|
73
|
+
FakeImportedModule
|
|
74
|
+
]
|
|
75
|
+
});
|
|
76
|
+
expect(dynamicModule.imports).toEqual(expect.arrayContaining([
|
|
77
|
+
FakeImportedModule
|
|
78
|
+
]));
|
|
79
|
+
});
|
|
80
|
+
it('should still wire the adapter/gateway providers when using async registration', ()=>{
|
|
81
|
+
const dynamicModule = NotificationModule.forRootAsync({
|
|
82
|
+
useFactory: jest.fn()
|
|
83
|
+
});
|
|
84
|
+
const adapterProvider = dynamicModule.providers.find((p)=>p.provide === NOTIFICATION_ADAPTER);
|
|
85
|
+
expect(adapterProvider.useClass).toBe(NotificationAdapter);
|
|
86
|
+
});
|
|
87
|
+
it('useFactory: builds a NOTIFICATION_MODULE_OPTIONS provider merging resolved config', async ()=>{
|
|
88
|
+
const useFactory = jest.fn().mockResolvedValue({
|
|
89
|
+
foo: 'bar'
|
|
90
|
+
});
|
|
91
|
+
const dynamicModule = NotificationModule.forRootAsync({
|
|
92
|
+
useFactory,
|
|
93
|
+
inject: [
|
|
94
|
+
'SOME_TOKEN'
|
|
95
|
+
]
|
|
96
|
+
});
|
|
97
|
+
const provider = dynamicModule.providers.find((p)=>p.provide === NOTIFICATION_MODULE_OPTIONS);
|
|
98
|
+
expect(provider.inject).toEqual([
|
|
99
|
+
'SOME_TOKEN'
|
|
100
|
+
]);
|
|
101
|
+
const resolved = await provider.useFactory('injected');
|
|
102
|
+
expect(useFactory).toHaveBeenCalledWith('injected');
|
|
103
|
+
expect(resolved.config).toEqual({
|
|
104
|
+
foo: 'bar'
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
it('useClass: registers both the class provider and an options provider calling createNotificationOptions()', async ()=>{
|
|
108
|
+
let MyOptionsFactory = class MyOptionsFactory {
|
|
109
|
+
constructor(){
|
|
110
|
+
_define_property(this, "createNotificationOptions", jest.fn().mockResolvedValue({
|
|
111
|
+
foo: 'from-class'
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const dynamicModule = NotificationModule.forRootAsync({
|
|
116
|
+
useClass: MyOptionsFactory
|
|
117
|
+
});
|
|
118
|
+
const classProvider = dynamicModule.providers.find((p)=>p.provide === MyOptionsFactory);
|
|
119
|
+
expect(classProvider.useClass).toBe(MyOptionsFactory);
|
|
120
|
+
const optionsProvider = dynamicModule.providers.find((p)=>p.provide === NOTIFICATION_MODULE_OPTIONS);
|
|
121
|
+
const instance = new MyOptionsFactory();
|
|
122
|
+
const resolved = await optionsProvider.useFactory(instance);
|
|
123
|
+
expect(instance.createNotificationOptions).toHaveBeenCalled();
|
|
124
|
+
expect(resolved.config).toEqual({
|
|
125
|
+
foo: 'from-class'
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it('useExisting: registers a single options provider without a duplicate class registration', ()=>{
|
|
129
|
+
let MyOptionsFactory = class MyOptionsFactory {
|
|
130
|
+
constructor(){
|
|
131
|
+
_define_property(this, "createNotificationOptions", jest.fn());
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const dynamicModule = NotificationModule.forRootAsync({
|
|
135
|
+
useExisting: MyOptionsFactory
|
|
136
|
+
});
|
|
137
|
+
expect(dynamicModule.providers.filter((p)=>p.provide === MyOptionsFactory)).toHaveLength(0);
|
|
138
|
+
const optionsProvider = dynamicModule.providers.find((p)=>p.provide === NOTIFICATION_MODULE_OPTIONS);
|
|
139
|
+
expect(optionsProvider.inject).toEqual([
|
|
140
|
+
MyOptionsFactory
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
it('should register no NOTIFICATION_MODULE_OPTIONS provider when neither useFactory, useClass, nor useExisting is given', ()=>{
|
|
144
|
+
const dynamicModule = NotificationModule.forRootAsync({});
|
|
145
|
+
expect(providerTokens(dynamicModule.providers)).not.toContain(NOTIFICATION_MODULE_OPTIONS);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { NotificationConfigService } from './notification-config.service';
|
|
2
|
+
function buildOptions(overrides = {}) {
|
|
3
|
+
return {
|
|
4
|
+
bootstrapAppConfig: {
|
|
5
|
+
databaseMode: 'single',
|
|
6
|
+
enableCompanyFeature: false
|
|
7
|
+
},
|
|
8
|
+
config: {},
|
|
9
|
+
...overrides
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('NotificationConfigService', ()=>{
|
|
13
|
+
describe('isCompanyFeatureEnabled', ()=>{
|
|
14
|
+
it('returns true when bootstrapAppConfig enables the company feature', ()=>{
|
|
15
|
+
const service = new NotificationConfigService(buildOptions({
|
|
16
|
+
bootstrapAppConfig: {
|
|
17
|
+
databaseMode: 'single',
|
|
18
|
+
enableCompanyFeature: true
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
expect(service.isCompanyFeatureEnabled()).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('returns false when bootstrapAppConfig disables the company feature', ()=>{
|
|
24
|
+
const service = new NotificationConfigService(buildOptions({
|
|
25
|
+
bootstrapAppConfig: {
|
|
26
|
+
databaseMode: 'single',
|
|
27
|
+
enableCompanyFeature: false
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
30
|
+
expect(service.isCompanyFeatureEnabled()).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
it('defaults to false when bootstrapAppConfig is missing', ()=>{
|
|
33
|
+
const service = new NotificationConfigService(buildOptions({
|
|
34
|
+
bootstrapAppConfig: undefined
|
|
35
|
+
}));
|
|
36
|
+
expect(service.isCompanyFeatureEnabled()).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('getDatabaseMode', ()=>{
|
|
40
|
+
it('returns the configured database mode', ()=>{
|
|
41
|
+
const service = new NotificationConfigService(buildOptions({
|
|
42
|
+
bootstrapAppConfig: {
|
|
43
|
+
databaseMode: 'multi-tenant',
|
|
44
|
+
enableCompanyFeature: true
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
expect(service.getDatabaseMode()).toBe('multi-tenant');
|
|
48
|
+
});
|
|
49
|
+
it('defaults to "single" when bootstrapAppConfig is missing', ()=>{
|
|
50
|
+
const service = new NotificationConfigService(buildOptions({
|
|
51
|
+
bootstrapAppConfig: undefined
|
|
52
|
+
}));
|
|
53
|
+
expect(service.getDatabaseMode()).toBe('single');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('isMultiTenant', ()=>{
|
|
57
|
+
it('returns true when database mode is multi-tenant', ()=>{
|
|
58
|
+
const service = new NotificationConfigService(buildOptions({
|
|
59
|
+
bootstrapAppConfig: {
|
|
60
|
+
databaseMode: 'multi-tenant',
|
|
61
|
+
enableCompanyFeature: true
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
expect(service.isMultiTenant()).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it('returns false when database mode is single', ()=>{
|
|
67
|
+
const service = new NotificationConfigService(buildOptions({
|
|
68
|
+
bootstrapAppConfig: {
|
|
69
|
+
databaseMode: 'single',
|
|
70
|
+
enableCompanyFeature: false
|
|
71
|
+
}
|
|
72
|
+
}));
|
|
73
|
+
expect(service.isMultiTenant()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('getJwtSecret', ()=>{
|
|
77
|
+
it('returns the configured jwt secret', ()=>{
|
|
78
|
+
const service = new NotificationConfigService(buildOptions({
|
|
79
|
+
config: {
|
|
80
|
+
jwtSecret: 'top-secret'
|
|
81
|
+
}
|
|
82
|
+
}));
|
|
83
|
+
expect(service.getJwtSecret()).toBe('top-secret');
|
|
84
|
+
});
|
|
85
|
+
it('returns undefined when no jwt secret is configured', ()=>{
|
|
86
|
+
const service = new NotificationConfigService(buildOptions({
|
|
87
|
+
config: {}
|
|
88
|
+
}));
|
|
89
|
+
expect(service.getJwtSecret()).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
it('returns undefined when config is missing entirely', ()=>{
|
|
92
|
+
const service = new NotificationConfigService(buildOptions({
|
|
93
|
+
config: undefined
|
|
94
|
+
}));
|
|
95
|
+
expect(service.getJwtSecret()).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('getOptions', ()=>{
|
|
99
|
+
it('returns the raw options object passed to the constructor', ()=>{
|
|
100
|
+
const options = buildOptions({
|
|
101
|
+
config: {
|
|
102
|
+
jwtSecret: 'abc'
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const service = new NotificationConfigService(options);
|
|
106
|
+
expect(service.getOptions()).toBe(options);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { MultiTenantDataSourceService } from '@flusys/nestjs-shared/modules';
|
|
2
|
+
import { NotificationDataSourceProvider } from './notification-datasource.provider';
|
|
3
|
+
import { Notification, NotificationWithCompany } from '../entities';
|
|
4
|
+
function buildConfigService(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
|
|
7
|
+
getDatabaseMode: jest.fn().mockReturnValue('single'),
|
|
8
|
+
isMultiTenant: jest.fn().mockReturnValue(false),
|
|
9
|
+
getJwtSecret: jest.fn().mockReturnValue(undefined),
|
|
10
|
+
getOptions: jest.fn().mockReturnValue({
|
|
11
|
+
bootstrapAppConfig: {
|
|
12
|
+
databaseMode: 'single',
|
|
13
|
+
enableCompanyFeature: false
|
|
14
|
+
},
|
|
15
|
+
config: {
|
|
16
|
+
defaultDatabaseConfig: {
|
|
17
|
+
type: 'mysql',
|
|
18
|
+
host: 'localhost',
|
|
19
|
+
port: 3306,
|
|
20
|
+
username: 'root',
|
|
21
|
+
password: 'pass',
|
|
22
|
+
database: 'flusys'
|
|
23
|
+
},
|
|
24
|
+
tenants: []
|
|
25
|
+
}
|
|
26
|
+
}),
|
|
27
|
+
...overrides
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
describe('NotificationDataSourceProvider', ()=>{
|
|
31
|
+
let configService;
|
|
32
|
+
let provider;
|
|
33
|
+
beforeEach(()=>{
|
|
34
|
+
configService = buildConfigService();
|
|
35
|
+
provider = new NotificationDataSourceProvider(configService, undefined);
|
|
36
|
+
});
|
|
37
|
+
describe('getEnableCompanyFeatureForTenant', ()=>{
|
|
38
|
+
it('returns the tenant override when the tenant explicitly enables the company feature', ()=>{
|
|
39
|
+
const result = provider.getEnableCompanyFeatureForTenant({
|
|
40
|
+
id: 'tenant-1',
|
|
41
|
+
database: 'tenant_db',
|
|
42
|
+
enableCompanyFeature: true
|
|
43
|
+
});
|
|
44
|
+
expect(result).toBe(true);
|
|
45
|
+
expect(configService.isCompanyFeatureEnabled).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
it('returns the tenant override when the tenant explicitly disables the company feature', ()=>{
|
|
48
|
+
const result = provider.getEnableCompanyFeatureForTenant({
|
|
49
|
+
id: 'tenant-1',
|
|
50
|
+
database: 'tenant_db',
|
|
51
|
+
enableCompanyFeature: false
|
|
52
|
+
});
|
|
53
|
+
expect(result).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it('falls back to the config service when the tenant has no override', ()=>{
|
|
56
|
+
configService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
57
|
+
const result = provider.getEnableCompanyFeatureForTenant(undefined);
|
|
58
|
+
expect(result).toBe(true);
|
|
59
|
+
expect(configService.isCompanyFeatureEnabled).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('getNotificationEntities', ()=>{
|
|
63
|
+
it('returns [NotificationWithCompany] when enableCompanyFeature is explicitly true', async ()=>{
|
|
64
|
+
const entities = await provider.getNotificationEntities(true);
|
|
65
|
+
expect(entities).toEqual([
|
|
66
|
+
NotificationWithCompany
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
it('returns [Notification] when enableCompanyFeature is explicitly false', async ()=>{
|
|
70
|
+
const entities = await provider.getNotificationEntities(false);
|
|
71
|
+
expect(entities).toEqual([
|
|
72
|
+
Notification
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
it('falls back to the config service when enableCompanyFeature is not provided', async ()=>{
|
|
76
|
+
configService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
77
|
+
const entities = await provider.getNotificationEntities();
|
|
78
|
+
expect(entities).toEqual([
|
|
79
|
+
NotificationWithCompany
|
|
80
|
+
]);
|
|
81
|
+
expect(configService.isCompanyFeatureEnabled).toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('createDataSourceFromConfig', ()=>{
|
|
85
|
+
it('resolves company-aware entities and delegates to the parent implementation', async ()=>{
|
|
86
|
+
configService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
87
|
+
const fakeDataSource = {};
|
|
88
|
+
const superSpy = jest.spyOn(MultiTenantDataSourceService.prototype, 'createDataSourceFromConfig').mockResolvedValue(fakeDataSource);
|
|
89
|
+
const config = {
|
|
90
|
+
type: 'mysql',
|
|
91
|
+
host: 'localhost',
|
|
92
|
+
port: 3306,
|
|
93
|
+
username: 'root',
|
|
94
|
+
password: 'pass',
|
|
95
|
+
database: 'flusys'
|
|
96
|
+
};
|
|
97
|
+
const result = await provider.createDataSourceFromConfig(config);
|
|
98
|
+
expect(result).toBe(fakeDataSource);
|
|
99
|
+
expect(superSpy).toHaveBeenCalledWith(config, [
|
|
100
|
+
NotificationWithCompany
|
|
101
|
+
]);
|
|
102
|
+
superSpy.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
it('resolves core entities when the company feature is disabled', async ()=>{
|
|
105
|
+
configService.isCompanyFeatureEnabled.mockReturnValue(false);
|
|
106
|
+
const fakeDataSource = {};
|
|
107
|
+
const superSpy = jest.spyOn(MultiTenantDataSourceService.prototype, 'createDataSourceFromConfig').mockResolvedValue(fakeDataSource);
|
|
108
|
+
const config = {
|
|
109
|
+
type: 'mysql',
|
|
110
|
+
host: 'localhost',
|
|
111
|
+
port: 3306,
|
|
112
|
+
username: 'root',
|
|
113
|
+
password: 'pass',
|
|
114
|
+
database: 'flusys'
|
|
115
|
+
};
|
|
116
|
+
await provider.createDataSourceFromConfig(config);
|
|
117
|
+
expect(superSpy).toHaveBeenCalledWith(config, [
|
|
118
|
+
Notification
|
|
119
|
+
]);
|
|
120
|
+
superSpy.mockRestore();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('inherited configuration accessors', ()=>{
|
|
124
|
+
it('reports single database mode by default', ()=>{
|
|
125
|
+
expect(provider.getDatabaseMode()).toBe('single');
|
|
126
|
+
expect(provider.isMultiTenant()).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
it('returns null for the current tenant when no request is bound', ()=>{
|
|
129
|
+
expect(provider.getCurrentTenant()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Test } from '@nestjs/testing';
|
|
2
|
+
import { UtilsService } from '@flusys/nestjs-shared/modules';
|
|
3
|
+
import { NotificationType } from '@flusys/nestjs-shared/enums';
|
|
4
|
+
import { NotificationHelperService } from './notification-helper.service';
|
|
5
|
+
import { NotificationConfigService } from './notification-config.service';
|
|
6
|
+
import { NotificationDataSourceProvider } from './notification-datasource.provider';
|
|
7
|
+
import { NotificationGateway } from '../gateways';
|
|
8
|
+
import { Notification, NotificationWithCompany } from '../entities';
|
|
9
|
+
function createMockRepo() {
|
|
10
|
+
return {
|
|
11
|
+
save: jest.fn()
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function createMockDataSource(repo) {
|
|
15
|
+
return {
|
|
16
|
+
getRepository: jest.fn().mockReturnValue(repo)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('NotificationHelperService', ()=>{
|
|
20
|
+
let mockCacheManager;
|
|
21
|
+
let mockUtilsService;
|
|
22
|
+
let mockConfigService;
|
|
23
|
+
let mockDataSourceProvider;
|
|
24
|
+
let mockGateway;
|
|
25
|
+
let mockRepo;
|
|
26
|
+
let mockDataSource;
|
|
27
|
+
async function buildService(withGateway = true) {
|
|
28
|
+
const providers = [
|
|
29
|
+
NotificationHelperService,
|
|
30
|
+
{
|
|
31
|
+
provide: 'CACHE_INSTANCE',
|
|
32
|
+
useValue: mockCacheManager
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
provide: UtilsService,
|
|
36
|
+
useValue: mockUtilsService
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
provide: NotificationConfigService,
|
|
40
|
+
useValue: mockConfigService
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
provide: NotificationDataSourceProvider,
|
|
44
|
+
useValue: mockDataSourceProvider
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
if (withGateway) {
|
|
48
|
+
providers.push({
|
|
49
|
+
provide: NotificationGateway,
|
|
50
|
+
useValue: mockGateway
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const module = await Test.createTestingModule({
|
|
54
|
+
providers
|
|
55
|
+
}).compile();
|
|
56
|
+
return module.get(NotificationHelperService);
|
|
57
|
+
}
|
|
58
|
+
beforeEach(()=>{
|
|
59
|
+
mockRepo = createMockRepo();
|
|
60
|
+
mockDataSource = createMockDataSource(mockRepo);
|
|
61
|
+
mockCacheManager = {};
|
|
62
|
+
mockUtilsService = {
|
|
63
|
+
clearCache: jest.fn().mockResolvedValue(undefined)
|
|
64
|
+
};
|
|
65
|
+
mockConfigService = {
|
|
66
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
|
|
67
|
+
};
|
|
68
|
+
mockDataSourceProvider = {
|
|
69
|
+
getDataSource: jest.fn().mockResolvedValue(mockDataSource)
|
|
70
|
+
};
|
|
71
|
+
mockGateway = {
|
|
72
|
+
emitToUser: jest.fn(),
|
|
73
|
+
emitToUserInCompany: jest.fn(),
|
|
74
|
+
isUserOnline: jest.fn(),
|
|
75
|
+
getOnlineUserCount: jest.fn()
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
describe('send', ()=>{
|
|
79
|
+
it('saves the notification, clears the cache, and emits to the user in real time', async ()=>{
|
|
80
|
+
const service = await buildService();
|
|
81
|
+
const options = {
|
|
82
|
+
userId: 'user-1',
|
|
83
|
+
title: 'Hi',
|
|
84
|
+
message: 'body',
|
|
85
|
+
type: NotificationType.SUCCESS,
|
|
86
|
+
data: {
|
|
87
|
+
a: 1
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const saved = {
|
|
91
|
+
id: 'notif-1',
|
|
92
|
+
...options,
|
|
93
|
+
isRead: false
|
|
94
|
+
};
|
|
95
|
+
mockRepo.save.mockResolvedValue(saved);
|
|
96
|
+
const result = await service.send(options);
|
|
97
|
+
expect(mockDataSource.getRepository).toHaveBeenCalledWith(Notification);
|
|
98
|
+
expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
99
|
+
userId: 'user-1',
|
|
100
|
+
title: 'Hi',
|
|
101
|
+
isRead: false
|
|
102
|
+
}));
|
|
103
|
+
expect(mockUtilsService.clearCache).toHaveBeenCalledWith('notification', mockCacheManager);
|
|
104
|
+
expect(mockGateway.emitToUser).toHaveBeenCalledWith('user-1', 'notification:new', expect.objectContaining({
|
|
105
|
+
id: 'notif-1',
|
|
106
|
+
title: 'Hi'
|
|
107
|
+
}));
|
|
108
|
+
expect(result).toBe(saved);
|
|
109
|
+
});
|
|
110
|
+
it('defaults type to INFO and message/data to null when omitted', async ()=>{
|
|
111
|
+
const service = await buildService();
|
|
112
|
+
mockRepo.save.mockImplementation(async (entity)=>({
|
|
113
|
+
id: 'notif-2',
|
|
114
|
+
...entity
|
|
115
|
+
}));
|
|
116
|
+
await service.send({
|
|
117
|
+
userId: 'user-1',
|
|
118
|
+
title: 'Only title'
|
|
119
|
+
});
|
|
120
|
+
expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
121
|
+
type: NotificationType.INFO,
|
|
122
|
+
message: null,
|
|
123
|
+
data: null
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
it('does not emit in real time when realtime is explicitly false', async ()=>{
|
|
127
|
+
const service = await buildService();
|
|
128
|
+
mockRepo.save.mockResolvedValue({
|
|
129
|
+
id: 'notif-3',
|
|
130
|
+
userId: 'user-1',
|
|
131
|
+
title: 'Silent'
|
|
132
|
+
});
|
|
133
|
+
await service.send({
|
|
134
|
+
userId: 'user-1',
|
|
135
|
+
title: 'Silent',
|
|
136
|
+
realtime: false
|
|
137
|
+
});
|
|
138
|
+
expect(mockGateway.emitToUser).not.toHaveBeenCalled();
|
|
139
|
+
expect(mockGateway.emitToUserInCompany).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
it('does not throw when no gateway is bound (gateway not registered)', async ()=>{
|
|
142
|
+
const service = await buildService(false);
|
|
143
|
+
mockRepo.save.mockResolvedValue({
|
|
144
|
+
id: 'notif-4',
|
|
145
|
+
userId: 'user-1',
|
|
146
|
+
title: 'No gateway'
|
|
147
|
+
});
|
|
148
|
+
await expect(service.send({
|
|
149
|
+
userId: 'user-1',
|
|
150
|
+
title: 'No gateway'
|
|
151
|
+
})).resolves.toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
it('uses NotificationWithCompany and includes companyId when the company feature is enabled', async ()=>{
|
|
154
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
155
|
+
const service = await buildService();
|
|
156
|
+
mockRepo.save.mockImplementation(async (entity)=>({
|
|
157
|
+
id: 'notif-5',
|
|
158
|
+
...entity
|
|
159
|
+
}));
|
|
160
|
+
await service.send({
|
|
161
|
+
userId: 'user-1',
|
|
162
|
+
title: 'Scoped',
|
|
163
|
+
companyId: 'company-1'
|
|
164
|
+
});
|
|
165
|
+
expect(mockDataSource.getRepository).toHaveBeenCalledWith(NotificationWithCompany);
|
|
166
|
+
expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
167
|
+
companyId: 'company-1'
|
|
168
|
+
}));
|
|
169
|
+
expect(mockGateway.emitToUserInCompany).toHaveBeenCalledWith('user-1', 'company-1', 'notification:new', expect.any(Object));
|
|
170
|
+
});
|
|
171
|
+
it('omits companyId when the company feature is enabled but none was provided', async ()=>{
|
|
172
|
+
mockConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
173
|
+
const service = await buildService();
|
|
174
|
+
mockRepo.save.mockImplementation(async (entity)=>({
|
|
175
|
+
id: 'notif-6',
|
|
176
|
+
...entity
|
|
177
|
+
}));
|
|
178
|
+
await service.send({
|
|
179
|
+
userId: 'user-1',
|
|
180
|
+
title: 'No company'
|
|
181
|
+
});
|
|
182
|
+
const savedArg = mockRepo.save.mock.calls[0][0];
|
|
183
|
+
expect(savedArg.companyId).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
it('reuses the same cached data source across multiple calls', async ()=>{
|
|
186
|
+
const service = await buildService();
|
|
187
|
+
mockRepo.save.mockResolvedValue({
|
|
188
|
+
id: 'notif-7',
|
|
189
|
+
userId: 'user-1',
|
|
190
|
+
title: 'A'
|
|
191
|
+
});
|
|
192
|
+
await service.send({
|
|
193
|
+
userId: 'user-1',
|
|
194
|
+
title: 'A'
|
|
195
|
+
});
|
|
196
|
+
await service.send({
|
|
197
|
+
userId: 'user-1',
|
|
198
|
+
title: 'B'
|
|
199
|
+
});
|
|
200
|
+
expect(mockDataSourceProvider.getDataSource).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
describe('sendToMany', ()=>{
|
|
204
|
+
it('saves a notification for every user id and clears the cache once', async ()=>{
|
|
205
|
+
const service = await buildService();
|
|
206
|
+
const options = {
|
|
207
|
+
userIds: [
|
|
208
|
+
'user-1',
|
|
209
|
+
'user-2',
|
|
210
|
+
'user-3'
|
|
211
|
+
],
|
|
212
|
+
title: 'Broadcast'
|
|
213
|
+
};
|
|
214
|
+
mockRepo.save.mockImplementation(async (entities)=>entities.map((e, i)=>({
|
|
215
|
+
id: `notif-${i}`,
|
|
216
|
+
...e
|
|
217
|
+
})));
|
|
218
|
+
const result = await service.sendToMany(options);
|
|
219
|
+
expect(mockRepo.save).toHaveBeenCalledWith([
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
userId: 'user-1'
|
|
222
|
+
}),
|
|
223
|
+
expect.objectContaining({
|
|
224
|
+
userId: 'user-2'
|
|
225
|
+
}),
|
|
226
|
+
expect.objectContaining({
|
|
227
|
+
userId: 'user-3'
|
|
228
|
+
})
|
|
229
|
+
]);
|
|
230
|
+
expect(mockUtilsService.clearCache).toHaveBeenCalledTimes(1);
|
|
231
|
+
expect(result).toHaveLength(3);
|
|
232
|
+
});
|
|
233
|
+
it('handles an empty userIds array without saving or emitting', async ()=>{
|
|
234
|
+
const service = await buildService();
|
|
235
|
+
mockRepo.save.mockResolvedValue([]);
|
|
236
|
+
const result = await service.sendToMany({
|
|
237
|
+
userIds: [],
|
|
238
|
+
title: 'Nobody'
|
|
239
|
+
});
|
|
240
|
+
expect(mockRepo.save).toHaveBeenCalledWith([]);
|
|
241
|
+
expect(result).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
it('emits to each user asynchronously (after the microtask queue flushes) when realtime', async ()=>{
|
|
244
|
+
const service = await buildService();
|
|
245
|
+
mockRepo.save.mockImplementation(async (entities)=>entities.map((e, i)=>({
|
|
246
|
+
id: `notif-${i}`,
|
|
247
|
+
...e
|
|
248
|
+
})));
|
|
249
|
+
await service.sendToMany({
|
|
250
|
+
userIds: [
|
|
251
|
+
'user-1',
|
|
252
|
+
'user-2'
|
|
253
|
+
],
|
|
254
|
+
title: 'Broadcast'
|
|
255
|
+
});
|
|
256
|
+
expect(mockGateway.emitToUser).not.toHaveBeenCalled();
|
|
257
|
+
await new Promise((resolve)=>setImmediate(resolve));
|
|
258
|
+
expect(mockGateway.emitToUser).toHaveBeenCalledTimes(2);
|
|
259
|
+
expect(mockGateway.emitToUser).toHaveBeenCalledWith('user-1', 'notification:new', expect.any(Object));
|
|
260
|
+
expect(mockGateway.emitToUser).toHaveBeenCalledWith('user-2', 'notification:new', expect.any(Object));
|
|
261
|
+
});
|
|
262
|
+
it('does not schedule emits when realtime is false', async ()=>{
|
|
263
|
+
const service = await buildService();
|
|
264
|
+
mockRepo.save.mockImplementation(async (entities)=>entities.map((e, i)=>({
|
|
265
|
+
id: `notif-${i}`,
|
|
266
|
+
...e
|
|
267
|
+
})));
|
|
268
|
+
await service.sendToMany({
|
|
269
|
+
userIds: [
|
|
270
|
+
'user-1'
|
|
271
|
+
],
|
|
272
|
+
title: 'Silent',
|
|
273
|
+
realtime: false
|
|
274
|
+
});
|
|
275
|
+
await new Promise((resolve)=>setImmediate(resolve));
|
|
276
|
+
expect(mockGateway.emitToUser).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
describe('isUserOnline', ()=>{
|
|
280
|
+
it('delegates to the gateway when present', async ()=>{
|
|
281
|
+
const service = await buildService();
|
|
282
|
+
mockGateway.isUserOnline.mockReturnValue(true);
|
|
283
|
+
expect(service.isUserOnline('user-1')).toBe(true);
|
|
284
|
+
expect(mockGateway.isUserOnline).toHaveBeenCalledWith('user-1');
|
|
285
|
+
});
|
|
286
|
+
it('returns false when no gateway is bound', async ()=>{
|
|
287
|
+
const service = await buildService(false);
|
|
288
|
+
expect(service.isUserOnline('user-1')).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe('getOnlineUserCount', ()=>{
|
|
292
|
+
it('delegates to the gateway when present', async ()=>{
|
|
293
|
+
const service = await buildService();
|
|
294
|
+
mockGateway.getOnlineUserCount.mockReturnValue(5);
|
|
295
|
+
expect(service.getOnlineUserCount()).toBe(5);
|
|
296
|
+
});
|
|
297
|
+
it('returns 0 when no gateway is bound', async ()=>{
|
|
298
|
+
const service = await buildService(false);
|
|
299
|
+
expect(service.getOnlineUserCount()).toBe(0);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|