@osimatic/helpers-js 1.5.37 → 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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osimatic/helpers-js",
3
- "version": "1.5.37",
3
+ "version": "1.5.38",
4
4
  "main": "main.js",
5
5
  "scripts": {
6
6
  "test": "jest",
@@ -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&param=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
+ });