@osimatic/helpers-js 1.5.36 → 1.5.38
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/multiple_action_in_table.js +29 -0
- package/network.js +20 -0
- package/package.json +1 -1
- package/tests/network.test.js +63 -0
- package/tests/web_push_notification.test.js +465 -0
|
@@ -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,6 +102,10 @@ class MultipleActionInTable {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
static getDivBtn(table) {
|
|
105
|
+
table = toEl(table);
|
|
106
|
+
if (!table) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
100
109
|
const divTableResponsive = table.parentElement;
|
|
101
110
|
let divBtn = divTableResponsive.nextElementSibling;
|
|
102
111
|
if (divBtn && divBtn.classList.contains('action_multiple_buttons')) {
|
|
@@ -110,6 +119,11 @@ class MultipleActionInTable {
|
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
static showButtonsAction(table) {
|
|
122
|
+
table = toEl(table);
|
|
123
|
+
if (!table) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
const divBtn = MultipleActionInTable.getDivBtn(table);
|
|
114
128
|
if (divBtn == null) {
|
|
115
129
|
return;
|
|
@@ -204,6 +218,11 @@ class MultipleActionInDivList {
|
|
|
204
218
|
}
|
|
205
219
|
|
|
206
220
|
static updateCheckbox(contentDiv) {
|
|
221
|
+
contentDiv = toEl(contentDiv);
|
|
222
|
+
if (!contentDiv) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
MultipleActionInDivList.showButtonsAction(contentDiv);
|
|
208
227
|
|
|
209
228
|
const allCheckbox = contentDiv.querySelectorAll('input.action_multiple_checkbox');
|
|
@@ -224,6 +243,11 @@ class MultipleActionInDivList {
|
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
static getButtonsDiv(contentDiv) {
|
|
246
|
+
contentDiv = toEl(contentDiv);
|
|
247
|
+
if (!contentDiv) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
227
251
|
const buttonsDiv = contentDiv.nextElementSibling;
|
|
228
252
|
if (buttonsDiv && buttonsDiv.classList.contains('action_multiple_buttons')) {
|
|
229
253
|
return buttonsDiv;
|
|
@@ -232,6 +256,11 @@ class MultipleActionInDivList {
|
|
|
232
256
|
}
|
|
233
257
|
|
|
234
258
|
static showButtonsAction(contentDiv) {
|
|
259
|
+
contentDiv = toEl(contentDiv);
|
|
260
|
+
if (!contentDiv) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
235
264
|
const buttonsDiv = MultipleActionInDivList.getButtonsDiv(contentDiv);
|
|
236
265
|
if (buttonsDiv == null) {
|
|
237
266
|
return;
|
package/network.js
CHANGED
|
@@ -307,6 +307,26 @@ class UrlAndQueryString {
|
|
|
307
307
|
return str.substr(0, strpos);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Match query string parameters to a list of known form field names.
|
|
312
|
+
* Handles array fields (e.g. "foo[]"): if the field expects an array but the value is scalar, it wraps it.
|
|
313
|
+
* @param {object} queryStringFilters - key/value pairs from the query string
|
|
314
|
+
* @param {string[]} listOfPossibleFieldNames - list of accepted field names (e.g. ['foo', 'bar[]'])
|
|
315
|
+
* @returns {object} filtered and normalized parameters
|
|
316
|
+
*/
|
|
317
|
+
static matchQueryParamsToFormFields(queryStringFilters, listOfPossibleFieldNames) {
|
|
318
|
+
const result = {};
|
|
319
|
+
Object.keys(queryStringFilters).forEach(key => {
|
|
320
|
+
const value = queryStringFilters[key];
|
|
321
|
+
if (listOfPossibleFieldNames.indexOf(key + (Array.isArray(value) ? '[]' : '')) !== -1) {
|
|
322
|
+
result[key] = value;
|
|
323
|
+
} else if (!Array.isArray(value) && listOfPossibleFieldNames.indexOf(key + '[]') !== -1) {
|
|
324
|
+
result[key] = [value];
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
310
330
|
}
|
|
311
331
|
|
|
312
332
|
module.exports = { Cookie, UrlAndQueryString };
|
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
|
+
});
|