@flighthq/notification 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,832 @@
1
+ import type {
2
+ NotificationBackend,
3
+ NotificationCapabilities,
4
+ NotificationChannel,
5
+ NotificationPermission,
6
+ NotificationRequest,
7
+ NotificationSchedule,
8
+ ScheduledNotification,
9
+ } from '@flighthq/types';
10
+
11
+ import {
12
+ cancelScheduledNotification,
13
+ closeAllNotifications,
14
+ closeNotification,
15
+ createNotificationChannel,
16
+ createServiceWorkerNotificationBackend,
17
+ createWebNotificationBackend,
18
+ deleteNotificationChannel,
19
+ getActiveNotifications,
20
+ getLaunchNotification,
21
+ getNotificationBackend,
22
+ getNotificationCapabilities,
23
+ getNotificationChannels,
24
+ getNotificationPermission,
25
+ getPendingNotifications,
26
+ isNotificationSupported,
27
+ notifyServiceWorkerBackendAction,
28
+ onNotificationAction,
29
+ onNotificationClick,
30
+ onNotificationDismiss,
31
+ onNotificationReply,
32
+ onNotificationShow,
33
+ requestNotificationPermission,
34
+ scheduleNotification,
35
+ setNotificationBackend,
36
+ showNotification,
37
+ updateNotification,
38
+ } from './notification';
39
+
40
+ const WEB_CAPABILITIES: NotificationCapabilities = {
41
+ actions: false,
42
+ channels: false,
43
+ coldStart: false,
44
+ image: false,
45
+ listActive: false,
46
+ scheduling: true,
47
+ textReply: false,
48
+ };
49
+
50
+ function fakeBackend(): NotificationBackend & {
51
+ closedIds: string[];
52
+ lastTitle: string | null;
53
+ pendingList: ScheduledNotification[];
54
+ fireAction(id: string, actionId: string): void;
55
+ fireClick(id: string): void;
56
+ fireDismiss(id: string): void;
57
+ fireReply(id: string, actionId: string, text: string): void;
58
+ fireShow(id: string): void;
59
+ updatedId: string | null;
60
+ updatedPartial: Partial<NotificationRequest> | null;
61
+ } {
62
+ let clickListener: ((id: string) => void) | null = null;
63
+ let actionListener: ((id: string, actionId: string) => void) | null = null;
64
+ let dismissListener: ((id: string) => void) | null = null;
65
+ let replyListener: ((id: string, actionId: string, text: string) => void) | null = null;
66
+ let showListener: ((id: string) => void) | null = null;
67
+ return {
68
+ closedIds: [],
69
+ lastTitle: null,
70
+ pendingList: [],
71
+ updatedId: null,
72
+ updatedPartial: null,
73
+ cancelScheduledNotification(_id) {},
74
+ closeAllNotifications() {
75
+ this.closedIds.push('*');
76
+ },
77
+ closeNotification(id) {
78
+ this.closedIds.push(id);
79
+ },
80
+ getCapabilities() {
81
+ return { ...WEB_CAPABILITIES };
82
+ },
83
+ async getLaunchNotification() {
84
+ return null;
85
+ },
86
+ async getActiveNotifications() {
87
+ return [];
88
+ },
89
+ async getPendingNotifications() {
90
+ return this.pendingList;
91
+ },
92
+ getPermission() {
93
+ return 'granted';
94
+ },
95
+ isSupported() {
96
+ return true;
97
+ },
98
+ async notify(request) {
99
+ this.lastTitle = request.title;
100
+ return request.id ?? 'generated-id';
101
+ },
102
+ async requestPermission(): Promise<NotificationPermission> {
103
+ return 'granted';
104
+ },
105
+ async scheduleNotification(request, _schedule) {
106
+ return request.id ?? 'sched-id';
107
+ },
108
+ subscribeAction(listener) {
109
+ actionListener = listener;
110
+ return () => {
111
+ actionListener = null;
112
+ };
113
+ },
114
+ subscribeClick(listener) {
115
+ clickListener = listener;
116
+ return () => {
117
+ clickListener = null;
118
+ };
119
+ },
120
+ subscribeDismiss(listener) {
121
+ dismissListener = listener;
122
+ return () => {
123
+ dismissListener = null;
124
+ };
125
+ },
126
+ subscribeReply(listener) {
127
+ replyListener = listener;
128
+ return () => {
129
+ replyListener = null;
130
+ };
131
+ },
132
+ subscribeShow(listener) {
133
+ showListener = listener;
134
+ return () => {
135
+ showListener = null;
136
+ };
137
+ },
138
+ async updateNotification(id, partial) {
139
+ this.updatedId = id;
140
+ this.updatedPartial = partial;
141
+ return true;
142
+ },
143
+ fireAction(id, actionId) {
144
+ actionListener?.(id, actionId);
145
+ },
146
+ fireClick(id) {
147
+ clickListener?.(id);
148
+ },
149
+ fireDismiss(id) {
150
+ dismissListener?.(id);
151
+ },
152
+ fireReply(id, actionId, text) {
153
+ replyListener?.(id, actionId, text);
154
+ },
155
+ fireShow(id) {
156
+ showListener?.(id);
157
+ },
158
+ };
159
+ }
160
+
161
+ afterEach(() => setNotificationBackend(null));
162
+
163
+ describe('cancelScheduledNotification', () => {
164
+ it('delegates to the active backend', () => {
165
+ const backend = fakeBackend();
166
+ let cancelledId: string | null = null;
167
+ backend.cancelScheduledNotification = (id) => {
168
+ cancelledId = id;
169
+ };
170
+ setNotificationBackend(backend);
171
+ cancelScheduledNotification('sched-1');
172
+ expect(cancelledId).toBe('sched-1');
173
+ });
174
+ });
175
+
176
+ describe('closeAllNotifications', () => {
177
+ it('delegates to the active backend', () => {
178
+ const backend = fakeBackend();
179
+ setNotificationBackend(backend);
180
+ closeAllNotifications();
181
+ expect(backend.closedIds).toContain('*');
182
+ });
183
+ });
184
+
185
+ describe('closeNotification', () => {
186
+ it('delegates to the active backend', () => {
187
+ const backend = fakeBackend();
188
+ setNotificationBackend(backend);
189
+ closeNotification('msg-1');
190
+ expect(backend.closedIds).toEqual(['msg-1']);
191
+ });
192
+ });
193
+
194
+ describe('createNotificationChannel', () => {
195
+ it('calls createNotificationChannel on backends that support it', () => {
196
+ const backend = fakeBackend() as ReturnType<typeof fakeBackend> & {
197
+ createNotificationChannel?: (c: Readonly<NotificationChannel>) => void;
198
+ receivedChannel?: NotificationChannel;
199
+ };
200
+ backend.createNotificationChannel = (c) => {
201
+ backend.receivedChannel = c as NotificationChannel;
202
+ };
203
+ setNotificationBackend(backend);
204
+ const channel: NotificationChannel = { id: 'updates', name: 'Updates' };
205
+ createNotificationChannel(channel);
206
+ expect(backend.receivedChannel).toEqual(channel);
207
+ });
208
+
209
+ it('no-ops on backends without the method', () => {
210
+ const backend = fakeBackend();
211
+ setNotificationBackend(backend);
212
+ expect(() => createNotificationChannel({ id: 'x', name: 'X' })).not.toThrow();
213
+ });
214
+ });
215
+
216
+ describe('createServiceWorkerNotificationBackend', () => {
217
+ function fakeRegistration() {
218
+ const shown: Array<{ title: string; options?: NotificationOptions }> = [];
219
+ return {
220
+ shown,
221
+ async showNotification(title: string, options?: NotificationOptions) {
222
+ shown.push({ title, options });
223
+ },
224
+ async getNotifications(_filter?: { tag?: string }) {
225
+ return shown.map((n) => ({
226
+ title: n.title,
227
+ tag: (n.options?.tag as string) ?? '',
228
+ close() {
229
+ const idx = shown.indexOf(n);
230
+ if (idx !== -1) shown.splice(idx, 1);
231
+ },
232
+ }));
233
+ },
234
+ };
235
+ }
236
+
237
+ it('returns a backend object satisfying the NotificationBackend interface', () => {
238
+ const reg = fakeRegistration();
239
+ const backend = createServiceWorkerNotificationBackend(reg);
240
+ expect(typeof backend.notify).toBe('function');
241
+ expect(typeof backend.requestPermission).toBe('function');
242
+ expect(typeof backend.isSupported).toBe('function');
243
+ expect(typeof backend.subscribeAction).toBe('function');
244
+ expect(typeof backend.subscribeClick).toBe('function');
245
+ expect(typeof backend.subscribeDismiss).toBe('function');
246
+ expect(typeof backend.subscribeShow).toBe('function');
247
+ expect(typeof backend.updateNotification).toBe('function');
248
+ });
249
+
250
+ it('getCapabilities reports actions: true', () => {
251
+ const reg = fakeRegistration();
252
+ const backend = createServiceWorkerNotificationBackend(reg);
253
+ const caps = backend.getCapabilities();
254
+ expect(caps.actions).toBe(true);
255
+ expect(caps.scheduling).toBe(true);
256
+ expect(caps.listActive).toBe(true);
257
+ });
258
+
259
+ it('getPermission returns a valid tri-state value', () => {
260
+ const reg = fakeRegistration();
261
+ const backend = createServiceWorkerNotificationBackend(reg);
262
+ const perm = backend.getPermission();
263
+ expect(['default', 'granted', 'denied']).toContain(perm);
264
+ });
265
+
266
+ it('getLaunchNotification returns null', async () => {
267
+ const reg = fakeRegistration();
268
+ const backend = createServiceWorkerNotificationBackend(reg);
269
+ expect(await backend.getLaunchNotification()).toBeNull();
270
+ });
271
+
272
+ it('scheduleNotification returns a non-empty id and getPendingNotifications lists it', async () => {
273
+ const reg = fakeRegistration();
274
+ const backend = createServiceWorkerNotificationBackend(reg);
275
+ const schedule: NotificationSchedule = { at: Date.now() + 60_000 };
276
+ const id = await backend.scheduleNotification({ title: 'SW Reminder' }, schedule);
277
+ expect(typeof id).toBe('string');
278
+ expect(id.length).toBeGreaterThan(0);
279
+ const pending = await backend.getPendingNotifications();
280
+ expect(pending.length).toBe(1);
281
+ expect(pending[0]!.id).toBe(id);
282
+ backend.cancelScheduledNotification(id);
283
+ });
284
+
285
+ it('cancelScheduledNotification removes from pending list', async () => {
286
+ const reg = fakeRegistration();
287
+ const backend = createServiceWorkerNotificationBackend(reg);
288
+ const schedule: NotificationSchedule = { at: Date.now() + 60_000 };
289
+ const id = await backend.scheduleNotification({ title: 'SW Soon' }, schedule);
290
+ backend.cancelScheduledNotification(id);
291
+ const pending = await backend.getPendingNotifications();
292
+ expect(pending.find((p) => p.id === id)).toBeUndefined();
293
+ });
294
+
295
+ it('subscribeAction unsubscribe stops delivery', () => {
296
+ const reg = fakeRegistration();
297
+ const backend = createServiceWorkerNotificationBackend(reg);
298
+ let count = 0;
299
+ const unsub = backend.subscribeAction(() => {
300
+ count += 1;
301
+ });
302
+ unsub();
303
+ expect(count).toBe(0);
304
+ });
305
+
306
+ it('requestPermission returns a valid NotificationPermission', async () => {
307
+ const reg = fakeRegistration();
308
+ const backend = createServiceWorkerNotificationBackend(reg);
309
+ const perm = await backend.requestPermission();
310
+ expect(['default', 'granted', 'denied']).toContain(perm);
311
+ });
312
+ });
313
+
314
+ describe('createWebNotificationBackend', () => {
315
+ it('returns sentinels without throwing in jsdom', async () => {
316
+ const backend = createWebNotificationBackend();
317
+ expect(typeof backend.isSupported()).toBe('boolean');
318
+ // requestPermission now returns NotificationPermission (tri-state string), not boolean.
319
+ expect(['default', 'granted', 'denied']).toContain(await backend.requestPermission());
320
+ expect(typeof (await backend.notify({ title: 'hi' }))).toBe('string');
321
+ expect(typeof backend.subscribeClick(() => {})).toBe('function');
322
+ expect(typeof backend.subscribeAction(() => {})).toBe('function');
323
+ expect(typeof backend.subscribeDismiss(() => {})).toBe('function');
324
+ expect(typeof backend.subscribeReply(() => {})).toBe('function');
325
+ expect(typeof backend.subscribeShow(() => {})).toBe('function');
326
+ expect(() => backend.closeNotification('x')).not.toThrow();
327
+ expect(() => backend.closeAllNotifications()).not.toThrow();
328
+ });
329
+
330
+ it('getPermission returns a valid tri-state value in jsdom', () => {
331
+ const backend = createWebNotificationBackend();
332
+ const perm = backend.getPermission();
333
+ expect(['default', 'granted', 'denied']).toContain(perm);
334
+ });
335
+
336
+ it('getCapabilities matches the expected web shape', () => {
337
+ const backend = createWebNotificationBackend();
338
+ expect(backend.getCapabilities()).toEqual(WEB_CAPABILITIES);
339
+ });
340
+
341
+ it('getLaunchNotification returns null on web', async () => {
342
+ const backend = createWebNotificationBackend();
343
+ expect(await backend.getLaunchNotification()).toBeNull();
344
+ });
345
+
346
+ it('getActiveNotifications returns an empty array on web', async () => {
347
+ const backend = createWebNotificationBackend();
348
+ expect(await backend.getActiveNotifications()).toEqual([]);
349
+ });
350
+
351
+ it('scheduleNotification returns a non-empty id and getPendingNotifications lists it', async () => {
352
+ const backend = createWebNotificationBackend();
353
+ const schedule: NotificationSchedule = { at: Date.now() + 60_000 };
354
+ const id = await backend.scheduleNotification({ title: 'Reminder' }, schedule);
355
+ expect(typeof id).toBe('string');
356
+ expect(id.length).toBeGreaterThan(0);
357
+ const pending = await backend.getPendingNotifications();
358
+ expect(pending.length).toBe(1);
359
+ expect(pending[0]!.id).toBe(id);
360
+ backend.cancelScheduledNotification(id);
361
+ });
362
+
363
+ it('cancelScheduledNotification removes from pending list', async () => {
364
+ const backend = createWebNotificationBackend();
365
+ const schedule: NotificationSchedule = { at: Date.now() + 60_000 };
366
+ const id = await backend.scheduleNotification({ title: 'Soon' }, schedule);
367
+ backend.cancelScheduledNotification(id);
368
+ const pending = await backend.getPendingNotifications();
369
+ expect(pending.find((p) => p.id === id)).toBeUndefined();
370
+ });
371
+
372
+ it('subscribeClick unsubscribe stops delivery', () => {
373
+ const backend = createWebNotificationBackend();
374
+ let count = 0;
375
+ const unsub = backend.subscribeClick(() => {
376
+ count += 1;
377
+ });
378
+ unsub();
379
+ // No way to trigger a click in jsdom but we at least verify unsubscribe does not throw.
380
+ expect(count).toBe(0);
381
+ });
382
+
383
+ it('subscribeAction unsubscribe is callable without throwing', () => {
384
+ const backend = createWebNotificationBackend();
385
+ const unsub = backend.subscribeAction(() => {});
386
+ expect(() => unsub()).not.toThrow();
387
+ });
388
+
389
+ it('subscribeReply unsubscribe is callable without throwing', () => {
390
+ const backend = createWebNotificationBackend();
391
+ const unsub = backend.subscribeReply(() => {});
392
+ expect(() => unsub()).not.toThrow();
393
+ });
394
+
395
+ it('subscribeShow unsubscribe is callable without throwing', () => {
396
+ const backend = createWebNotificationBackend();
397
+ const unsub = backend.subscribeShow(() => {});
398
+ expect(() => unsub()).not.toThrow();
399
+ });
400
+
401
+ it('subscribeDismiss unsubscribe is callable without throwing', () => {
402
+ const backend = createWebNotificationBackend();
403
+ const unsub = backend.subscribeDismiss(() => {});
404
+ expect(() => unsub()).not.toThrow();
405
+ });
406
+ });
407
+
408
+ describe('deleteNotificationChannel', () => {
409
+ it('calls deleteNotificationChannel on backends that support it', () => {
410
+ const backend = fakeBackend() as ReturnType<typeof fakeBackend> & {
411
+ deleteNotificationChannel?: (id: string) => void;
412
+ deletedChannelId?: string;
413
+ };
414
+ backend.deleteNotificationChannel = (id) => {
415
+ backend.deletedChannelId = id;
416
+ };
417
+ setNotificationBackend(backend);
418
+ deleteNotificationChannel('updates');
419
+ expect(backend.deletedChannelId).toBe('updates');
420
+ });
421
+
422
+ it('no-ops on backends without the method', () => {
423
+ const backend = fakeBackend();
424
+ setNotificationBackend(backend);
425
+ expect(() => deleteNotificationChannel('x')).not.toThrow();
426
+ });
427
+ });
428
+
429
+ describe('getActiveNotifications', () => {
430
+ it('delegates to the active backend', async () => {
431
+ const backend = fakeBackend();
432
+ setNotificationBackend(backend);
433
+ const result = await getActiveNotifications();
434
+ expect(Array.isArray(result)).toBe(true);
435
+ });
436
+ });
437
+
438
+ describe('getLaunchNotification', () => {
439
+ it('delegates to the active backend', async () => {
440
+ const backend = fakeBackend();
441
+ setNotificationBackend(backend);
442
+ const result = await getLaunchNotification();
443
+ expect(result).toBeNull();
444
+ });
445
+ });
446
+
447
+ describe('getNotificationBackend', () => {
448
+ it('falls back to a web backend', () => {
449
+ expect(getNotificationBackend()).not.toBeNull();
450
+ });
451
+
452
+ it('returns the registered backend', () => {
453
+ const backend = fakeBackend();
454
+ setNotificationBackend(backend);
455
+ expect(getNotificationBackend()).toBe(backend);
456
+ });
457
+ });
458
+
459
+ describe('getNotificationCapabilities', () => {
460
+ it('delegates to the active backend', () => {
461
+ const backend = fakeBackend();
462
+ setNotificationBackend(backend);
463
+ const caps = getNotificationCapabilities();
464
+ expect(typeof caps.actions).toBe('boolean');
465
+ expect(typeof caps.channels).toBe('boolean');
466
+ expect(typeof caps.scheduling).toBe('boolean');
467
+ });
468
+ });
469
+
470
+ describe('getNotificationChannels', () => {
471
+ it('returns an empty array on backends without channel support', () => {
472
+ const backend = fakeBackend();
473
+ setNotificationBackend(backend);
474
+ expect(getNotificationChannels()).toEqual([]);
475
+ });
476
+
477
+ it('calls getNotificationChannels on backends that support it', () => {
478
+ const channel: NotificationChannel = { id: 'ch1', name: 'Ch 1' };
479
+ const backend = fakeBackend() as ReturnType<typeof fakeBackend> & {
480
+ getNotificationChannels?: () => ReadonlyArray<Readonly<NotificationChannel>>;
481
+ };
482
+ backend.getNotificationChannels = () => [channel];
483
+ setNotificationBackend(backend);
484
+ expect(getNotificationChannels()).toEqual([channel]);
485
+ });
486
+ });
487
+
488
+ describe('getNotificationPermission', () => {
489
+ it('delegates to the active backend', () => {
490
+ const backend = fakeBackend();
491
+ setNotificationBackend(backend);
492
+ expect(getNotificationPermission()).toBe('granted');
493
+ });
494
+
495
+ it('returns a valid tri-state from the web backend', () => {
496
+ const perm = getNotificationPermission();
497
+ expect(['default', 'granted', 'denied']).toContain(perm);
498
+ });
499
+ });
500
+
501
+ describe('getPendingNotifications', () => {
502
+ it('delegates to the active backend', async () => {
503
+ const req: NotificationRequest = { title: 'sched', id: 'sn-1' };
504
+ const schedule: NotificationSchedule = { at: Date.now() + 10_000 };
505
+ const backend = fakeBackend();
506
+ backend.pendingList = [{ id: 'sn-1', request: req, schedule }];
507
+ setNotificationBackend(backend);
508
+ const list = await getPendingNotifications();
509
+ expect(list.length).toBe(1);
510
+ expect(list[0]!.id).toBe('sn-1');
511
+ });
512
+ });
513
+
514
+ describe('isNotificationSupported', () => {
515
+ it('delegates to the active backend', () => {
516
+ setNotificationBackend(fakeBackend());
517
+ expect(isNotificationSupported()).toBe(true);
518
+ });
519
+
520
+ it('returns a boolean from the web backend', () => {
521
+ expect(typeof isNotificationSupported()).toBe('boolean');
522
+ });
523
+ });
524
+
525
+ describe('notifyServiceWorkerBackendAction', () => {
526
+ function fakeSwRegistration() {
527
+ return {
528
+ async showNotification(_title: string, _options?: NotificationOptions) {},
529
+ async getNotifications(_filter?: { tag?: string }) {
530
+ return [];
531
+ },
532
+ };
533
+ }
534
+
535
+ it('delivers action events to subscribeAction listeners', () => {
536
+ const backend = createServiceWorkerNotificationBackend(fakeSwRegistration());
537
+ let received: [string, string] | null = null;
538
+ backend.subscribeAction((id, actionId) => {
539
+ received = [id, actionId];
540
+ });
541
+ notifyServiceWorkerBackendAction(backend, {
542
+ type: 'notificationclick',
543
+ notificationId: 'n1',
544
+ actionId: 'reply',
545
+ });
546
+ expect(received).toEqual(['n1', 'reply']);
547
+ });
548
+
549
+ it('delivers inline-reply text to subscribeReply listeners when reply is present', () => {
550
+ const backend = createServiceWorkerNotificationBackend(fakeSwRegistration());
551
+ let received: [string, string, string] | null = null;
552
+ backend.subscribeReply((id, actionId, text) => {
553
+ received = [id, actionId, text];
554
+ });
555
+ notifyServiceWorkerBackendAction(backend, {
556
+ type: 'notificationclick',
557
+ notificationId: 'chat',
558
+ actionId: 'quick-reply',
559
+ reply: 'Hello back!',
560
+ });
561
+ expect(received).toEqual(['chat', 'quick-reply', 'Hello back!']);
562
+ });
563
+
564
+ it('routes a reply message to reply listeners only, not action listeners', () => {
565
+ const backend = createServiceWorkerNotificationBackend(fakeSwRegistration());
566
+ let actionFired = false;
567
+ backend.subscribeAction(() => {
568
+ actionFired = true;
569
+ });
570
+ notifyServiceWorkerBackendAction(backend, {
571
+ type: 'notificationclick',
572
+ notificationId: 'chat',
573
+ actionId: 'quick-reply',
574
+ reply: 'Hi',
575
+ });
576
+ expect(actionFired).toBe(false);
577
+ });
578
+
579
+ it('delivers click events to subscribeClick listeners when no actionId', () => {
580
+ const backend = createServiceWorkerNotificationBackend(fakeSwRegistration());
581
+ let received: string | null = null;
582
+ backend.subscribeClick((id) => {
583
+ received = id;
584
+ });
585
+ notifyServiceWorkerBackendAction(backend, {
586
+ type: 'notificationclick',
587
+ notificationId: 'n2',
588
+ });
589
+ expect(received).toBe('n2');
590
+ });
591
+
592
+ it('no-ops for non-notificationclick message types', () => {
593
+ const backend = createServiceWorkerNotificationBackend(fakeSwRegistration());
594
+ let called = false;
595
+ backend.subscribeClick(() => {
596
+ called = true;
597
+ });
598
+ notifyServiceWorkerBackendAction(backend, {
599
+ type: 'push',
600
+ notificationId: 'n3',
601
+ });
602
+ expect(called).toBe(false);
603
+ });
604
+
605
+ it('no-ops gracefully on a non-SW backend', () => {
606
+ const backend = fakeBackend();
607
+ setNotificationBackend(backend);
608
+ expect(() =>
609
+ notifyServiceWorkerBackendAction(backend, { type: 'notificationclick', notificationId: 'x' }),
610
+ ).not.toThrow();
611
+ setNotificationBackend(null);
612
+ });
613
+ });
614
+
615
+ describe('onNotificationAction', () => {
616
+ it('delivers id and action id via the active backend', () => {
617
+ const backend = fakeBackend();
618
+ setNotificationBackend(backend);
619
+ let receivedId: string | null = null;
620
+ let receivedAction: string | null = null;
621
+ onNotificationAction((id, actionId) => {
622
+ receivedId = id;
623
+ receivedAction = actionId;
624
+ });
625
+ backend.fireAction('chat', 'reply');
626
+ expect(receivedId).toBe('chat');
627
+ expect(receivedAction).toBe('reply');
628
+ });
629
+
630
+ it('returns an unsubscribe that stops delivery', () => {
631
+ const backend = fakeBackend();
632
+ setNotificationBackend(backend);
633
+ let count = 0;
634
+ const unsubscribe = onNotificationAction(() => {
635
+ count += 1;
636
+ });
637
+ backend.fireAction('a', 'x');
638
+ unsubscribe();
639
+ backend.fireAction('a', 'x');
640
+ expect(count).toBe(1);
641
+ });
642
+ });
643
+
644
+ describe('onNotificationClick', () => {
645
+ it('delivers the notification id via the active backend', () => {
646
+ const backend = fakeBackend();
647
+ setNotificationBackend(backend);
648
+ let received: string | null = null;
649
+ onNotificationClick((id) => {
650
+ received = id;
651
+ });
652
+ backend.fireClick('chat');
653
+ expect(received).toBe('chat');
654
+ });
655
+
656
+ it('returns an unsubscribe that stops delivery', () => {
657
+ const backend = fakeBackend();
658
+ setNotificationBackend(backend);
659
+ let count = 0;
660
+ const unsubscribe = onNotificationClick(() => {
661
+ count += 1;
662
+ });
663
+ backend.fireClick('a');
664
+ unsubscribe();
665
+ backend.fireClick('a');
666
+ expect(count).toBe(1);
667
+ });
668
+ });
669
+
670
+ describe('onNotificationDismiss', () => {
671
+ it('delivers the notification id via the active backend', () => {
672
+ const backend = fakeBackend();
673
+ setNotificationBackend(backend);
674
+ let received: string | null = null;
675
+ onNotificationDismiss((id) => {
676
+ received = id;
677
+ });
678
+ backend.fireDismiss('notif-42');
679
+ expect(received).toBe('notif-42');
680
+ });
681
+
682
+ it('returns an unsubscribe that stops delivery', () => {
683
+ const backend = fakeBackend();
684
+ setNotificationBackend(backend);
685
+ let count = 0;
686
+ const unsubscribe = onNotificationDismiss(() => {
687
+ count += 1;
688
+ });
689
+ backend.fireDismiss('a');
690
+ unsubscribe();
691
+ backend.fireDismiss('a');
692
+ expect(count).toBe(1);
693
+ });
694
+ });
695
+
696
+ describe('onNotificationReply', () => {
697
+ it('delivers id, actionId, and reply text via the active backend', () => {
698
+ const backend = fakeBackend();
699
+ setNotificationBackend(backend);
700
+ let received: [string, string, string] | null = null;
701
+ onNotificationReply((id, actionId, text) => {
702
+ received = [id, actionId, text];
703
+ });
704
+ backend.fireReply('chat', 'reply', 'Hello back!');
705
+ expect(received).toEqual(['chat', 'reply', 'Hello back!']);
706
+ });
707
+
708
+ it('returns an unsubscribe that stops delivery', () => {
709
+ const backend = fakeBackend();
710
+ setNotificationBackend(backend);
711
+ let count = 0;
712
+ const unsubscribe = onNotificationReply(() => {
713
+ count += 1;
714
+ });
715
+ backend.fireReply('a', 'r', 'x');
716
+ unsubscribe();
717
+ backend.fireReply('a', 'r', 'x');
718
+ expect(count).toBe(1);
719
+ });
720
+ });
721
+
722
+ describe('onNotificationShow', () => {
723
+ it('delivers the notification id via the active backend', () => {
724
+ const backend = fakeBackend();
725
+ setNotificationBackend(backend);
726
+ let received: string | null = null;
727
+ onNotificationShow((id) => {
728
+ received = id;
729
+ });
730
+ backend.fireShow('notif-1');
731
+ expect(received).toBe('notif-1');
732
+ });
733
+
734
+ it('returns an unsubscribe that stops delivery', () => {
735
+ const backend = fakeBackend();
736
+ setNotificationBackend(backend);
737
+ let count = 0;
738
+ const unsubscribe = onNotificationShow(() => {
739
+ count += 1;
740
+ });
741
+ backend.fireShow('a');
742
+ unsubscribe();
743
+ backend.fireShow('a');
744
+ expect(count).toBe(1);
745
+ });
746
+ });
747
+
748
+ describe('requestNotificationPermission', () => {
749
+ it('delegates to the active backend and returns a tri-state NotificationPermission', async () => {
750
+ setNotificationBackend(fakeBackend());
751
+ const result = await requestNotificationPermission();
752
+ expect(['default', 'granted', 'denied']).toContain(result);
753
+ expect(result).toBe('granted');
754
+ });
755
+
756
+ it('returns a valid tri-state from the web backend in jsdom', async () => {
757
+ const result = await requestNotificationPermission();
758
+ expect(['default', 'granted', 'denied']).toContain(result);
759
+ });
760
+ });
761
+
762
+ describe('scheduleNotification', () => {
763
+ it('echoes the supplied id', async () => {
764
+ const backend = fakeBackend();
765
+ setNotificationBackend(backend);
766
+ const schedule: NotificationSchedule = { at: Date.now() + 5_000 };
767
+ const id = await scheduleNotification({ title: 'Future', id: 'my-id' }, schedule);
768
+ expect(id).toBe('my-id');
769
+ });
770
+
771
+ it('returns a generated id when request has no id', async () => {
772
+ const backend = fakeBackend();
773
+ setNotificationBackend(backend);
774
+ const schedule: NotificationSchedule = { at: Date.now() + 5_000 };
775
+ const id = await scheduleNotification({ title: 'Future' }, schedule);
776
+ expect(typeof id).toBe('string');
777
+ expect(id.length).toBeGreaterThan(0);
778
+ });
779
+ });
780
+
781
+ describe('setNotificationBackend', () => {
782
+ it('clears back to the web fallback when passed null', () => {
783
+ setNotificationBackend(fakeBackend());
784
+ setNotificationBackend(null);
785
+ expect(getNotificationBackend()).not.toBeNull();
786
+ });
787
+ });
788
+
789
+ describe('showNotification', () => {
790
+ it('delegates to the active backend and returns a string id', async () => {
791
+ const backend = fakeBackend();
792
+ setNotificationBackend(backend);
793
+ const id = await showNotification({ title: 'Hello', body: 'there' });
794
+ expect(typeof id).toBe('string');
795
+ expect(id.length).toBeGreaterThan(0);
796
+ expect(backend.lastTitle).toBe('Hello');
797
+ });
798
+
799
+ it('echoes the caller-supplied id', async () => {
800
+ const backend = fakeBackend();
801
+ setNotificationBackend(backend);
802
+ const id = await showNotification({ title: 'X', id: 'my-stable-id' });
803
+ expect(id).toBe('my-stable-id');
804
+ });
805
+
806
+ it('returns a string from the web backend without throwing in jsdom', async () => {
807
+ expect(typeof (await showNotification({ title: 'Hello' }))).toBe('string');
808
+ });
809
+ });
810
+
811
+ describe('updateNotification', () => {
812
+ it('delegates to the active backend', async () => {
813
+ const backend = fakeBackend();
814
+ setNotificationBackend(backend);
815
+ const result = await updateNotification('notif-1', { body: 'Updated body' });
816
+ expect(typeof result).toBe('boolean');
817
+ expect(backend.updatedId).toBe('notif-1');
818
+ expect(backend.updatedPartial).toEqual({ body: 'Updated body' });
819
+ });
820
+
821
+ it('returns false when the notification is not live on the web backend', async () => {
822
+ const result = await updateNotification('nonexistent-id', { body: 'New body' });
823
+ expect(result).toBe(false);
824
+ });
825
+
826
+ it('web backend updateNotification no-ops on title-less partial without live entry', async () => {
827
+ const { createWebNotificationBackend: create } = await import('./notification');
828
+ const backend = create();
829
+ const result = await backend.updateNotification('missing', { body: 'x' });
830
+ expect(result).toBe(false);
831
+ });
832
+ });