@osimatic/helpers-js 1.5.37 → 1.5.39
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.
|
@@ -77,6 +77,11 @@ class MultipleActionInTable {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
static updateCheckbox(table) {
|
|
80
|
+
table = toEl(table);
|
|
81
|
+
if (!table) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
MultipleActionInTable.showButtonsAction(table);
|
|
81
86
|
|
|
82
87
|
const allCheckbox = table.querySelectorAll('input.action_multiple_checkbox');
|
|
@@ -97,19 +102,29 @@ class MultipleActionInTable {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
static getDivBtn(table) {
|
|
105
|
+
const isJquery = typeof jQuery !== 'undefined' && table instanceof jQuery;
|
|
106
|
+
table = toEl(table);
|
|
107
|
+
if (!table) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
100
110
|
const divTableResponsive = table.parentElement;
|
|
101
111
|
let divBtn = divTableResponsive.nextElementSibling;
|
|
102
112
|
if (divBtn && divBtn.classList.contains('action_multiple_buttons')) {
|
|
103
|
-
return divBtn;
|
|
113
|
+
return isJquery ? jQuery(divBtn) : divBtn;
|
|
104
114
|
}
|
|
105
115
|
divBtn = divTableResponsive.parentElement?.parentElement?.parentElement?.nextElementSibling;
|
|
106
116
|
if (divBtn && divBtn.classList.contains('action_multiple_buttons')) {
|
|
107
|
-
return divBtn;
|
|
117
|
+
return isJquery ? jQuery(divBtn) : divBtn;
|
|
108
118
|
}
|
|
109
119
|
return null;
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
static showButtonsAction(table) {
|
|
123
|
+
table = toEl(table);
|
|
124
|
+
if (!table) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
const divBtn = MultipleActionInTable.getDivBtn(table);
|
|
114
129
|
if (divBtn == null) {
|
|
115
130
|
return;
|
|
@@ -204,6 +219,11 @@ class MultipleActionInDivList {
|
|
|
204
219
|
}
|
|
205
220
|
|
|
206
221
|
static updateCheckbox(contentDiv) {
|
|
222
|
+
contentDiv = toEl(contentDiv);
|
|
223
|
+
if (!contentDiv) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
207
227
|
MultipleActionInDivList.showButtonsAction(contentDiv);
|
|
208
228
|
|
|
209
229
|
const allCheckbox = contentDiv.querySelectorAll('input.action_multiple_checkbox');
|
|
@@ -224,6 +244,11 @@ class MultipleActionInDivList {
|
|
|
224
244
|
}
|
|
225
245
|
|
|
226
246
|
static getButtonsDiv(contentDiv) {
|
|
247
|
+
contentDiv = toEl(contentDiv);
|
|
248
|
+
if (!contentDiv) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
227
252
|
const buttonsDiv = contentDiv.nextElementSibling;
|
|
228
253
|
if (buttonsDiv && buttonsDiv.classList.contains('action_multiple_buttons')) {
|
|
229
254
|
return buttonsDiv;
|
|
@@ -232,6 +257,11 @@ class MultipleActionInDivList {
|
|
|
232
257
|
}
|
|
233
258
|
|
|
234
259
|
static showButtonsAction(contentDiv) {
|
|
260
|
+
contentDiv = toEl(contentDiv);
|
|
261
|
+
if (!contentDiv) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
235
265
|
const buttonsDiv = MultipleActionInDivList.getButtonsDiv(contentDiv);
|
|
236
266
|
if (buttonsDiv == null) {
|
|
237
267
|
return;
|
package/package.json
CHANGED
package/tests/network.test.js
CHANGED
|
@@ -465,6 +465,69 @@ describe('UrlAndQueryString', () => {
|
|
|
465
465
|
});
|
|
466
466
|
});
|
|
467
467
|
|
|
468
|
+
describe('matchQueryParamsToFormFields', () => {
|
|
469
|
+
test('should keep scalar param matching a scalar field', () => {
|
|
470
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
471
|
+
{ name: 'John' },
|
|
472
|
+
['name', 'age']
|
|
473
|
+
);
|
|
474
|
+
expect(result).toEqual({ name: 'John' });
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('should keep array param matching an array field', () => {
|
|
478
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
479
|
+
{ tags: ['a', 'b'] },
|
|
480
|
+
['tags[]']
|
|
481
|
+
);
|
|
482
|
+
expect(result).toEqual({ tags: ['a', 'b'] });
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('should wrap scalar into array when field expects array', () => {
|
|
486
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
487
|
+
{ tags: 'a' },
|
|
488
|
+
['tags[]']
|
|
489
|
+
);
|
|
490
|
+
expect(result).toEqual({ tags: ['a'] });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('should exclude params not in the list', () => {
|
|
494
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
495
|
+
{ name: 'John', unknown: 'x' },
|
|
496
|
+
['name']
|
|
497
|
+
);
|
|
498
|
+
expect(result).toEqual({ name: 'John' });
|
|
499
|
+
expect(result).not.toHaveProperty('unknown');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('should handle mix of scalar and array fields', () => {
|
|
503
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
504
|
+
{ name: 'John', tags: ['a', 'b'], page: '1' },
|
|
505
|
+
['name', 'tags[]', 'page']
|
|
506
|
+
);
|
|
507
|
+
expect(result).toEqual({ name: 'John', tags: ['a', 'b'], page: '1' });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('should return empty object when no params match', () => {
|
|
511
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
512
|
+
{ foo: 'bar' },
|
|
513
|
+
['name', 'tags[]']
|
|
514
|
+
);
|
|
515
|
+
expect(result).toEqual({});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('should return empty object when inputs are empty', () => {
|
|
519
|
+
expect(UrlAndQueryString.matchQueryParamsToFormFields({}, [])).toEqual({});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('should not include array param when field only accepts scalar', () => {
|
|
523
|
+
const result = UrlAndQueryString.matchQueryParamsToFormFields(
|
|
524
|
+
{ name: ['a', 'b'] },
|
|
525
|
+
['name']
|
|
526
|
+
);
|
|
527
|
+
expect(result).toEqual({});
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
468
531
|
describe('getQuery (deprecated)', () => {
|
|
469
532
|
test('should get query string from URL', () => {
|
|
470
533
|
const result = UrlAndQueryString.getQuery('https://example.com/path?query=1¶m=2');
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
const { WebPushNotification } = require('../web_push_notification');
|
|
5
|
+
|
|
6
|
+
describe('WebPushNotification', () => {
|
|
7
|
+
let mockSub;
|
|
8
|
+
let mockPushManager;
|
|
9
|
+
let mockSwReg;
|
|
10
|
+
let mockServiceWorker;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockSub = {
|
|
14
|
+
endpoint: 'https://push.example.com/endpoint',
|
|
15
|
+
unsubscribe: jest.fn().mockResolvedValue(true),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
mockPushManager = {
|
|
19
|
+
getSubscription: jest.fn().mockResolvedValue(null),
|
|
20
|
+
subscribe: jest.fn().mockResolvedValue(mockSub),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
mockSwReg = {
|
|
24
|
+
scope: '/',
|
|
25
|
+
pushManager: mockPushManager,
|
|
26
|
+
unregister: jest.fn().mockResolvedValue(true),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
mockServiceWorker = {
|
|
30
|
+
register: jest.fn().mockResolvedValue(mockSwReg),
|
|
31
|
+
getRegistration: jest.fn().mockResolvedValue(mockSwReg),
|
|
32
|
+
addEventListener: jest.fn(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true, configurable: true });
|
|
36
|
+
Object.defineProperty(window, 'PushManager', { value: {}, writable: true, configurable: true });
|
|
37
|
+
Object.defineProperty(window, 'Notification', {
|
|
38
|
+
value: { permission: 'granted', requestPermission: jest.fn().mockResolvedValue('granted') },
|
|
39
|
+
writable: true,
|
|
40
|
+
configurable: true,
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(navigator, 'serviceWorker', {
|
|
43
|
+
value: mockServiceWorker,
|
|
44
|
+
writable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
49
|
+
json: jest.fn().mockResolvedValue({ success: true }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
WebPushNotification.init({
|
|
53
|
+
vapidPublicKey: 'dGVzdA',
|
|
54
|
+
subscriberUrl: 'https://example.com/subscribe',
|
|
55
|
+
serviceWorkerPath: '/sw.js',
|
|
56
|
+
httpHeaders: { Authorization: 'Bearer token' },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
jest.clearAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// =========================================================================
|
|
65
|
+
|
|
66
|
+
describe('init', () => {
|
|
67
|
+
test('should store all config properties', () => {
|
|
68
|
+
WebPushNotification.init({
|
|
69
|
+
vapidPublicKey: 'key123',
|
|
70
|
+
subscriberUrl: 'https://example.com/sub',
|
|
71
|
+
unsubscribeUrl: 'https://example.com/unsub',
|
|
72
|
+
serviceWorkerPath: '/worker.js',
|
|
73
|
+
httpHeaders: { 'X-Token': 'abc' },
|
|
74
|
+
});
|
|
75
|
+
expect(WebPushNotification._vapidPublicKey).toBe('key123');
|
|
76
|
+
expect(WebPushNotification._subscriberUrl).toBe('https://example.com/sub');
|
|
77
|
+
expect(WebPushNotification._unsubscribeUrl).toBe('https://example.com/unsub');
|
|
78
|
+
expect(WebPushNotification._serviceWorkerPath).toBe('/worker.js');
|
|
79
|
+
expect(WebPushNotification._httpHeaders).toEqual({ 'X-Token': 'abc' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('should default httpHeaders to empty object', () => {
|
|
83
|
+
WebPushNotification.init({ vapidPublicKey: 'k', subscriberUrl: 'u', serviceWorkerPath: '/sw.js' });
|
|
84
|
+
expect(WebPushNotification._httpHeaders).toEqual({});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('should default unsubscribeUrl to null', () => {
|
|
88
|
+
WebPushNotification.init({ vapidPublicKey: 'k', subscriberUrl: 'u', serviceWorkerPath: '/sw.js' });
|
|
89
|
+
expect(WebPushNotification._unsubscribeUrl).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('should reset _messageListenerRegistered to false', () => {
|
|
93
|
+
WebPushNotification._messageListenerRegistered = true;
|
|
94
|
+
WebPushNotification.init({ vapidPublicKey: 'k', subscriberUrl: 'u', serviceWorkerPath: '/sw.js' });
|
|
95
|
+
expect(WebPushNotification._messageListenerRegistered).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// =========================================================================
|
|
100
|
+
|
|
101
|
+
describe('isAvailable', () => {
|
|
102
|
+
test('should return false when not in secure context', () => {
|
|
103
|
+
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
|
104
|
+
expect(WebPushNotification.isAvailable()).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('should return false when PushManager is missing', () => {
|
|
108
|
+
// PushManager was added as a configurable own property in beforeEach — we can delete it
|
|
109
|
+
delete window.PushManager;
|
|
110
|
+
expect(WebPushNotification.isAvailable()).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('should return false when Notification is missing', () => {
|
|
114
|
+
// Notification was added as a configurable own property in beforeEach — we can delete it
|
|
115
|
+
delete window.Notification;
|
|
116
|
+
expect(WebPushNotification.isAvailable()).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('should return true when all requirements are met', () => {
|
|
120
|
+
expect(WebPushNotification.isAvailable()).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// =========================================================================
|
|
125
|
+
|
|
126
|
+
describe('subscribe', () => {
|
|
127
|
+
test('should return early if not available', async () => {
|
|
128
|
+
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
|
129
|
+
const result = await WebPushNotification.subscribe();
|
|
130
|
+
expect(result).toBeUndefined();
|
|
131
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should request permission when permission is default', async () => {
|
|
135
|
+
window.Notification = {
|
|
136
|
+
permission: 'default',
|
|
137
|
+
requestPermission: jest.fn().mockImplementation(async () => {
|
|
138
|
+
window.Notification.permission = 'granted';
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
await WebPushNotification.subscribe();
|
|
142
|
+
expect(window.Notification.requestPermission).toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('should return early if permission is denied', async () => {
|
|
146
|
+
window.Notification = { permission: 'denied', requestPermission: jest.fn() };
|
|
147
|
+
const result = await WebPushNotification.subscribe();
|
|
148
|
+
expect(result).toBeUndefined();
|
|
149
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('should return early if SW registration fails', async () => {
|
|
153
|
+
mockServiceWorker.register.mockRejectedValue(new Error('Registration failed'));
|
|
154
|
+
const result = await WebPushNotification.subscribe();
|
|
155
|
+
expect(result).toBeUndefined();
|
|
156
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('should reuse existing subscription', async () => {
|
|
160
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
161
|
+
await WebPushNotification.subscribe();
|
|
162
|
+
expect(mockPushManager.subscribe).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('should create new subscription when none exists', async () => {
|
|
166
|
+
await WebPushNotification.subscribe();
|
|
167
|
+
expect(mockPushManager.subscribe).toHaveBeenCalledWith({
|
|
168
|
+
applicationServerKey: expect.any(Uint8Array),
|
|
169
|
+
userVisibleOnly: true,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('should call saveSubscription and return server response', async () => {
|
|
174
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
175
|
+
const result = await WebPushNotification.subscribe();
|
|
176
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
177
|
+
'https://example.com/subscribe',
|
|
178
|
+
expect.objectContaining({ method: 'post' })
|
|
179
|
+
);
|
|
180
|
+
expect(result).toEqual({ success: true });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// =========================================================================
|
|
185
|
+
|
|
186
|
+
describe('saveSubscription', () => {
|
|
187
|
+
test('should POST to subscriberUrl', async () => {
|
|
188
|
+
await WebPushNotification.saveSubscription(mockSub);
|
|
189
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
190
|
+
'https://example.com/subscribe',
|
|
191
|
+
expect.objectContaining({ method: 'post' })
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('should include Content-Type header', async () => {
|
|
196
|
+
await WebPushNotification.saveSubscription(mockSub);
|
|
197
|
+
expect(fetch.mock.calls[0][1].headers['Content-Type']).toBe('application/json');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('should include custom httpHeaders', async () => {
|
|
201
|
+
await WebPushNotification.saveSubscription(mockSub);
|
|
202
|
+
expect(fetch.mock.calls[0][1].headers['Authorization']).toBe('Bearer token');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('should not mutate the original httpHeaders', async () => {
|
|
206
|
+
await WebPushNotification.saveSubscription(mockSub);
|
|
207
|
+
expect(WebPushNotification._httpHeaders['Content-Type']).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('should include subscription data in body', async () => {
|
|
211
|
+
await WebPushNotification.saveSubscription(mockSub);
|
|
212
|
+
const body = JSON.parse(fetch.mock.calls[0][1].body);
|
|
213
|
+
expect(body.endpoint).toBe('https://push.example.com/endpoint');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('should include userAgent in body', async () => {
|
|
217
|
+
await WebPushNotification.saveSubscription(mockSub);
|
|
218
|
+
const body = JSON.parse(fetch.mock.calls[0][1].body);
|
|
219
|
+
expect(body.userAgent).toBe(navigator.userAgent);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('should return parsed server response', async () => {
|
|
223
|
+
const result = await WebPushNotification.saveSubscription(mockSub);
|
|
224
|
+
expect(result).toEqual({ success: true });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// =========================================================================
|
|
229
|
+
|
|
230
|
+
describe('deleteSubscription', () => {
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
WebPushNotification.init({
|
|
233
|
+
vapidPublicKey: 'dGVzdA',
|
|
234
|
+
subscriberUrl: 'https://example.com/subscribe',
|
|
235
|
+
unsubscribeUrl: 'https://example.com/unsubscribe',
|
|
236
|
+
serviceWorkerPath: '/sw.js',
|
|
237
|
+
httpHeaders: { Authorization: 'Bearer token' },
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('should DELETE to unsubscribeUrl', async () => {
|
|
242
|
+
await WebPushNotification.deleteSubscription(mockSub);
|
|
243
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
244
|
+
'https://example.com/unsubscribe',
|
|
245
|
+
expect.objectContaining({ method: 'delete' })
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('should include Content-Type header', async () => {
|
|
250
|
+
await WebPushNotification.deleteSubscription(mockSub);
|
|
251
|
+
expect(fetch.mock.calls[0][1].headers['Content-Type']).toBe('application/json');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('should include userAgent in body', async () => {
|
|
255
|
+
await WebPushNotification.deleteSubscription(mockSub);
|
|
256
|
+
const body = JSON.parse(fetch.mock.calls[0][1].body);
|
|
257
|
+
expect(body.userAgent).toBe(navigator.userAgent);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('should return parsed server response', async () => {
|
|
261
|
+
const result = await WebPushNotification.deleteSubscription(mockSub);
|
|
262
|
+
expect(result).toEqual({ success: true });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// =========================================================================
|
|
267
|
+
|
|
268
|
+
describe('unsubscribe', () => {
|
|
269
|
+
test('should return early if not available', async () => {
|
|
270
|
+
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
|
271
|
+
await WebPushNotification.unsubscribe();
|
|
272
|
+
expect(mockServiceWorker.getRegistration).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('should call _unregisterServiceWorker even when no subscription', async () => {
|
|
276
|
+
mockPushManager.getSubscription.mockResolvedValue(null);
|
|
277
|
+
const unregisterSpy = jest.spyOn(WebPushNotification, '_unregisterServiceWorker').mockResolvedValue();
|
|
278
|
+
await WebPushNotification.unsubscribe();
|
|
279
|
+
expect(unregisterSpy).toHaveBeenCalled();
|
|
280
|
+
unregisterSpy.mockRestore();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('should handle missing registration gracefully', async () => {
|
|
284
|
+
mockServiceWorker.getRegistration.mockResolvedValue(undefined);
|
|
285
|
+
const unregisterSpy = jest.spyOn(WebPushNotification, '_unregisterServiceWorker').mockResolvedValue();
|
|
286
|
+
await expect(WebPushNotification.unsubscribe()).resolves.not.toThrow();
|
|
287
|
+
unregisterSpy.mockRestore();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('should call sub.unsubscribe() when subscription exists', async () => {
|
|
291
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
292
|
+
await WebPushNotification.unsubscribe();
|
|
293
|
+
expect(mockSub.unsubscribe).toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('should not call deleteSubscription when unsubscribeUrl is not set', async () => {
|
|
297
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
298
|
+
const deleteSpy = jest.spyOn(WebPushNotification, 'deleteSubscription');
|
|
299
|
+
await WebPushNotification.unsubscribe();
|
|
300
|
+
expect(deleteSpy).not.toHaveBeenCalled();
|
|
301
|
+
deleteSpy.mockRestore();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('should call deleteSubscription when unsubscribeUrl is set', async () => {
|
|
305
|
+
WebPushNotification.init({
|
|
306
|
+
vapidPublicKey: 'dGVzdA',
|
|
307
|
+
subscriberUrl: 'https://example.com/subscribe',
|
|
308
|
+
unsubscribeUrl: 'https://example.com/unsubscribe',
|
|
309
|
+
serviceWorkerPath: '/sw.js',
|
|
310
|
+
});
|
|
311
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
312
|
+
await WebPushNotification.unsubscribe();
|
|
313
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
314
|
+
'https://example.com/unsubscribe',
|
|
315
|
+
expect.objectContaining({ method: 'delete' })
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('should call sub.unsubscribe() after deleteSubscription', async () => {
|
|
320
|
+
WebPushNotification.init({
|
|
321
|
+
vapidPublicKey: 'dGVzdA',
|
|
322
|
+
subscriberUrl: 'https://example.com/subscribe',
|
|
323
|
+
unsubscribeUrl: 'https://example.com/unsubscribe',
|
|
324
|
+
serviceWorkerPath: '/sw.js',
|
|
325
|
+
});
|
|
326
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
327
|
+
await WebPushNotification.unsubscribe();
|
|
328
|
+
expect(mockSub.unsubscribe).toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// =========================================================================
|
|
333
|
+
|
|
334
|
+
describe('isSubscribed', () => {
|
|
335
|
+
test('should return false when not available', async () => {
|
|
336
|
+
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
|
|
337
|
+
expect(await WebPushNotification.isSubscribed()).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('should return false when no service worker registration', async () => {
|
|
341
|
+
mockServiceWorker.getRegistration.mockResolvedValue(undefined);
|
|
342
|
+
expect(await WebPushNotification.isSubscribed()).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('should return false when no subscription', async () => {
|
|
346
|
+
mockPushManager.getSubscription.mockResolvedValue(null);
|
|
347
|
+
expect(await WebPushNotification.isSubscribed()).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('should return true when subscription exists', async () => {
|
|
351
|
+
mockPushManager.getSubscription.mockResolvedValue(mockSub);
|
|
352
|
+
expect(await WebPushNotification.isSubscribed()).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// =========================================================================
|
|
357
|
+
|
|
358
|
+
describe('_registerServiceWorker', () => {
|
|
359
|
+
test('should register SW with correct path and scope', async () => {
|
|
360
|
+
await WebPushNotification._registerServiceWorker();
|
|
361
|
+
expect(mockServiceWorker.register).toHaveBeenCalledWith('/sw.js', { scope: '/' });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('should return the registration on success', async () => {
|
|
365
|
+
const result = await WebPushNotification._registerServiceWorker();
|
|
366
|
+
expect(result).toBe(mockSwReg);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('should return null on failure', async () => {
|
|
370
|
+
mockServiceWorker.register.mockRejectedValue(new Error('Failed'));
|
|
371
|
+
const result = await WebPushNotification._registerServiceWorker();
|
|
372
|
+
expect(result).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// =========================================================================
|
|
377
|
+
|
|
378
|
+
describe('_unregisterServiceWorker', () => {
|
|
379
|
+
test('should do nothing when no registration exists', async () => {
|
|
380
|
+
mockServiceWorker.getRegistration.mockResolvedValue(undefined);
|
|
381
|
+
await expect(WebPushNotification._unregisterServiceWorker()).resolves.not.toThrow();
|
|
382
|
+
expect(mockSwReg.unregister).not.toHaveBeenCalled();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('should call unregister on the registration', async () => {
|
|
386
|
+
await WebPushNotification._unregisterServiceWorker();
|
|
387
|
+
expect(mockSwReg.unregister).toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// =========================================================================
|
|
392
|
+
|
|
393
|
+
describe('_initMessageListener', () => {
|
|
394
|
+
test('should register a message event listener', () => {
|
|
395
|
+
WebPushNotification._initMessageListener();
|
|
396
|
+
expect(mockServiceWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function));
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('should register the listener only once even if called multiple times', () => {
|
|
400
|
+
WebPushNotification._initMessageListener();
|
|
401
|
+
WebPushNotification._initMessageListener();
|
|
402
|
+
expect(mockServiceWorker.addEventListener).toHaveBeenCalledTimes(1);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('should call saveSubscription on RESUBSCRIBE message', async () => {
|
|
406
|
+
const saveSpy = jest.spyOn(WebPushNotification, 'saveSubscription').mockResolvedValue({});
|
|
407
|
+
WebPushNotification._initMessageListener();
|
|
408
|
+
const handler = mockServiceWorker.addEventListener.mock.calls[0][1];
|
|
409
|
+
await handler({ data: { type: 'RESUBSCRIBE', subscription: mockSub } });
|
|
410
|
+
expect(saveSpy).toHaveBeenCalledWith(mockSub);
|
|
411
|
+
saveSpy.mockRestore();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('should ignore messages with unknown type', async () => {
|
|
415
|
+
const saveSpy = jest.spyOn(WebPushNotification, 'saveSubscription').mockResolvedValue({});
|
|
416
|
+
WebPushNotification._initMessageListener();
|
|
417
|
+
const handler = mockServiceWorker.addEventListener.mock.calls[0][1];
|
|
418
|
+
await handler({ data: { type: 'OTHER' } });
|
|
419
|
+
expect(saveSpy).not.toHaveBeenCalled();
|
|
420
|
+
saveSpy.mockRestore();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('should ignore messages without data', async () => {
|
|
424
|
+
const saveSpy = jest.spyOn(WebPushNotification, 'saveSubscription').mockResolvedValue({});
|
|
425
|
+
WebPushNotification._initMessageListener();
|
|
426
|
+
const handler = mockServiceWorker.addEventListener.mock.calls[0][1];
|
|
427
|
+
await handler({ data: null });
|
|
428
|
+
expect(saveSpy).not.toHaveBeenCalled();
|
|
429
|
+
saveSpy.mockRestore();
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// =========================================================================
|
|
434
|
+
|
|
435
|
+
describe('_encodeToUint8Array', () => {
|
|
436
|
+
test('should return a Uint8Array', () => {
|
|
437
|
+
expect(WebPushNotification._encodeToUint8Array('dGVzdA')).toBeInstanceOf(Uint8Array);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('should decode base64url to correct bytes', () => {
|
|
441
|
+
// 'dGVzdA' is base64url for 'test' → [116, 101, 115, 116]
|
|
442
|
+
const result = WebPushNotification._encodeToUint8Array('dGVzdA');
|
|
443
|
+
expect(Array.from(result)).toEqual([116, 101, 115, 116]);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('should add missing padding automatically', () => {
|
|
447
|
+
// 'dGVzdA' (6 chars, needs 2 padding chars) must give same result as 'dGVzdA=='
|
|
448
|
+
const withoutPadding = WebPushNotification._encodeToUint8Array('dGVzdA');
|
|
449
|
+
const withPadding = WebPushNotification._encodeToUint8Array('dGVzdA==');
|
|
450
|
+
expect(Array.from(withoutPadding)).toEqual(Array.from(withPadding));
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('should convert base64url dash (-) to standard base64 plus (+)', () => {
|
|
454
|
+
// '-A' in base64url = '+A==' in base64 = byte 0xF8 = 248
|
|
455
|
+
const result = WebPushNotification._encodeToUint8Array('-A');
|
|
456
|
+
expect(Array.from(result)).toEqual([248]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('should convert base64url underscore (_) to standard base64 slash (/)', () => {
|
|
460
|
+
// '_w' in base64url = '/w==' in base64 = byte 0xFF = 255
|
|
461
|
+
const result = WebPushNotification._encodeToUint8Array('_w');
|
|
462
|
+
expect(Array.from(result)).toEqual([255]);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|