@micha.bigler/ui-core-micha 2.12.0 → 2.14.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.
@@ -2,9 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
2
2
 
3
3
  import { AuthContext } from '../auth/AuthContext';
4
4
  import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
5
-
6
- const INITIAL_BACKOFF_MS = 1_000;
7
- const MAX_BACKOFF_MS = 30_000;
5
+ import { DEFAULT_ENVELOPE, RealtimeContext, useRealtimeCore } from './realtime';
8
6
 
9
7
  const NotificationsContext = createContext(null);
10
8
 
@@ -62,7 +60,6 @@ export function NotificationsProvider({ children, wsUrlBase }) {
62
60
  const [notifications, setNotifications] = useState([]);
63
61
  const [unreadCount, setUnreadCount] = useState(0);
64
62
  const notificationsRef = useRef([]);
65
- const socketRef = useRef(null);
66
63
 
67
64
  const replaceNotifications = useCallback((nextNotifications) => {
68
65
  notificationsRef.current = nextNotifications;
@@ -84,99 +81,92 @@ export function NotificationsProvider({ children, wsUrlBase }) {
84
81
  return { notifications: nextNotifications, unreadCount: nextUnreadCount };
85
82
  }, [authenticated, replaceNotifications]);
86
83
 
87
- useEffect(() => {
88
- if (!authenticated) {
89
- replaceNotifications([]);
90
- setUnreadCount(0);
91
- return undefined;
92
- }
93
-
94
- let active = true;
95
- let reconnectTimer = null;
96
- let backoffMs = INITIAL_BACKOFF_MS;
84
+ // Seeded gates the socket connect, not just `authenticated`: the prior single-purpose
85
+ // socket owner awaited the initial REST feed/unread-count fetch before opening the
86
+ // WebSocket, so a live push could never race an empty/incomplete `notificationsRef`.
87
+ // Preserved here rather than connecting immediately, since `refresh()`'s
88
+ // `replaceNotifications(...)` is unconditional and would silently drop a message that
89
+ // arrived and patched state before the initial REST response landed.
90
+ const [seeded, setSeeded] = useState(false);
91
+
92
+ const { subscribe } = useRealtimeCore({
93
+ active: authenticated && seeded,
94
+ wsUrl: getWebSocketUrl(wsUrlBase),
95
+ });
97
96
 
98
- const patchNotifications = (updater) => {
99
- const nextNotifications = updater(notificationsRef.current);
97
+ const handleRealtimeMessage = useCallback((data) => {
98
+ if (data.type === 'notification.status') {
99
+ let unreadDelta = 0;
100
+ const nextNotifications = notificationsRef.current.map((notification) => {
101
+ if (notification.notification_id !== data.notification_id) return notification;
102
+
103
+ const wasUnread = !notification.seen && !notification.dismissed;
104
+ const patched = patchStatus(notification, data.status || {});
105
+ const isUnread = !patched.seen && !patched.dismissed;
106
+ if (wasUnread !== isUnread) {
107
+ unreadDelta += isUnread ? 1 : -1;
108
+ }
109
+ return patched;
110
+ });
100
111
  replaceNotifications(nextNotifications);
101
- };
102
-
103
- const handleMessage = (event) => {
104
- let data;
105
- try {
106
- data = JSON.parse(event.data);
107
- } catch {
108
- return;
112
+ if (unreadDelta) {
113
+ setUnreadCount((count) => Math.max(0, count + unreadDelta));
109
114
  }
115
+ return;
116
+ }
110
117
 
111
- if (data.type === 'notification.status') {
112
- let unreadDelta = 0;
113
- patchNotifications((current) => current.map((notification) => {
114
- if (notification.notification_id !== data.notification_id) return notification;
115
-
116
- const wasUnread = !notification.seen && !notification.dismissed;
117
- const patched = patchStatus(notification, data.status || {});
118
- const isUnread = !patched.seen && !patched.dismissed;
119
- if (wasUnread !== isUnread) {
120
- unreadDelta += isUnread ? 1 : -1;
121
- }
122
- return patched;
123
- }));
124
- if (unreadDelta) {
125
- setUnreadCount((count) => Math.max(0, count + unreadDelta));
126
- }
127
- return;
128
- }
118
+ const pushedNotification = normalizeNotificationPush(data);
119
+
120
+ // NOTIF-12 D2: a notification routed to more than one channel (e.g. chip
121
+ // + popup) arrives as multiple WS messages sharing one notification_id.
122
+ // Fold repeats into the existing feed entry (last-write-wins on content)
123
+ // instead of appending a duplicate and double-incrementing the badge.
124
+ const existingIndex = pushedNotification.notification_id == null
125
+ ? -1
126
+ : notificationsRef.current.findIndex(
127
+ (notification) => notification.notification_id === pushedNotification.notification_id,
128
+ );
129
+
130
+ if (existingIndex !== -1) {
131
+ const nextNotifications = notificationsRef.current.map((notification, index) => (
132
+ index === existingIndex
133
+ ? { ...notification, notification_type: pushedNotification.notification_type, content: pushedNotification.content }
134
+ : notification
135
+ ));
136
+ replaceNotifications(nextNotifications);
137
+ return;
138
+ }
129
139
 
130
- const pushedNotification = normalizeNotificationPush(data);
131
- patchNotifications((current) => [pushedNotification, ...current]);
132
- setUnreadCount((count) => count + 1);
133
- };
140
+ replaceNotifications([pushedNotification, ...notificationsRef.current]);
141
+ setUnreadCount((count) => count + 1);
142
+ }, [replaceNotifications]);
134
143
 
135
- const connect = () => {
136
- if (!active || socketRef.current) return;
137
-
138
- const socket = new WebSocket(getWebSocketUrl(wsUrlBase));
139
- socketRef.current = socket;
140
-
141
- socket.onopen = () => {
142
- backoffMs = INITIAL_BACKOFF_MS;
143
- };
144
- socket.onmessage = handleMessage;
145
- socket.onerror = () => {
146
- socket.close();
147
- };
148
- socket.onclose = () => {
149
- if (socketRef.current === socket) {
150
- socketRef.current = null;
151
- }
152
- if (!active || reconnectTimer) return;
153
- reconnectTimer = setTimeout(() => {
154
- reconnectTimer = null;
155
- connect();
156
- }, backoffMs);
157
- backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
158
- };
159
- };
144
+ useEffect(() => {
145
+ if (!authenticated) return undefined;
146
+ return subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage);
147
+ }, [authenticated, subscribe, handleRealtimeMessage]);
160
148
 
161
- const seedAndConnect = async () => {
162
- try {
163
- await refresh();
164
- } catch {
165
- // The live stream remains useful if the initial REST request is temporarily unavailable.
166
- }
167
- if (active) connect();
168
- };
149
+ useEffect(() => {
150
+ if (!authenticated) {
151
+ replaceNotifications([]);
152
+ setUnreadCount(0);
153
+ setSeeded(false);
154
+ return undefined;
155
+ }
169
156
 
170
- seedAndConnect();
157
+ let cancelled = false;
158
+ refresh()
159
+ .catch(() => {
160
+ // The live stream remains useful if the initial REST request is temporarily unavailable.
161
+ })
162
+ .finally(() => {
163
+ if (!cancelled) setSeeded(true);
164
+ });
171
165
 
172
166
  return () => {
173
- active = false;
174
- clearTimeout(reconnectTimer);
175
- const socket = socketRef.current;
176
- socketRef.current = null;
177
- if (socket) socket.close();
167
+ cancelled = true;
178
168
  };
179
- }, [authenticated, refresh, replaceNotifications, wsUrlBase]);
169
+ }, [authenticated, refresh, replaceNotifications]);
180
170
 
181
171
  const mark = useCallback((action, ids) => {
182
172
  const selectedIds = Array.isArray(ids) ? ids : [];
@@ -210,16 +200,18 @@ export function NotificationsProvider({ children, wsUrlBase }) {
210
200
  const markDone = useCallback((ids) => mark('done', ids), [mark]);
211
201
 
212
202
  return (
213
- <NotificationsContext.Provider value={{
214
- notifications,
215
- unreadCount,
216
- markSeen,
217
- markDismissed,
218
- markDone,
219
- refresh,
220
- }}>
221
- {children}
222
- </NotificationsContext.Provider>
203
+ <RealtimeContext.Provider value={{ subscribe }}>
204
+ <NotificationsContext.Provider value={{
205
+ notifications,
206
+ unreadCount,
207
+ markSeen,
208
+ markDismissed,
209
+ markDone,
210
+ refresh,
211
+ }}>
212
+ {children}
213
+ </NotificationsContext.Provider>
214
+ </RealtimeContext.Provider>
223
215
  );
224
216
  }
225
217
 
@@ -0,0 +1,103 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Button from '@mui/material/Button';
4
+ import Stack from '@mui/material/Stack';
5
+ import Typography from '@mui/material/Typography';
6
+ import { useOnboarding } from '../onboarding/OnboardingProvider';
7
+ import { WizardDialogShell } from '../onboarding/WizardDialogShell';
8
+ import { useNotifications } from './NotificationsProvider';
9
+ import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
10
+
11
+ function PopupStepComponent({ onDismiss, ctx }) {
12
+ const { t } = useTranslation();
13
+ const content = ctx?.content || {};
14
+
15
+ return (
16
+ <Stack spacing={2}>
17
+ {content.body_key && (
18
+ <Typography variant="body1">{t(content.body_key, content.params || {})}</Typography>
19
+ )}
20
+ <Button onClick={onDismiss} sx={{ alignSelf: 'flex-end' }}>
21
+ {t('PopupSurface.CLOSE')}
22
+ </Button>
23
+ </Stack>
24
+ );
25
+ }
26
+
27
+ const POPUP_STEP = { blocking: false, Component: PopupStepComponent };
28
+
29
+ /**
30
+ * Renders popup-eligible notifications (NOTIF-12) through the onboarding
31
+ * wizard's dialog shell, reusing only the renderer (design D-F7) — status
32
+ * lives on NotificationRecipient via the existing feed/mark path, never in
33
+ * the onboarding progress store.
34
+ *
35
+ * Deliberately session-live only: it watches the raw realtime stream for
36
+ * `channel === "popup"` messages rather than the already-seeded feed list,
37
+ * since the feed carries no per-channel discriminator (see NotificationsProvider).
38
+ */
39
+ export function PopupSurface() {
40
+ const { t } = useTranslation();
41
+ const { markSeen, markDismissed } = useNotifications();
42
+ const { subscribe } = useRealtime();
43
+ const onboarding = useOnboarding();
44
+ const [queue, setQueue] = useState([]);
45
+ const seenIdsRef = useRef(new Set());
46
+
47
+ const handleRealtimeMessage = useCallback((data) => {
48
+ if (data.type === 'notification.status') return;
49
+ if (data.channel !== 'popup') return;
50
+ setQueue((previous) => {
51
+ if (previous.some((item) => item.notification_id === data.notification_id)) return previous;
52
+ return [...previous, {
53
+ notification_id: data.notification_id,
54
+ // recipient_id is the id feed/mark/ actually resolves against
55
+ // (NotificationRecipient.pk, not Notification.pk). Fall back to
56
+ // notification_id only for an older dcm that predates it — matching
57
+ // NotificationsProvider's own normalizeNotificationPush precedent.
58
+ markId: data.recipient_id ?? data.notification_id,
59
+ content: data.content || {},
60
+ }];
61
+ });
62
+ }, []);
63
+
64
+ useEffect(() => subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage), [subscribe, handleRealtimeMessage]);
65
+
66
+ // Any active onboarding step — blocking or not — must win: OnboardingWizard
67
+ // renders its own dialog for as long as onboarding.activeSteps is non-empty,
68
+ // and two independent MUI Dialogs must never be mounted at once.
69
+ const onboardingActive = Boolean(onboarding?.activeSteps?.length);
70
+ const activePopup = queue[0];
71
+
72
+ useEffect(() => {
73
+ if (!activePopup || onboardingActive) return;
74
+ if (activePopup.markId == null) return;
75
+ if (seenIdsRef.current.has(activePopup.notification_id)) return;
76
+ seenIdsRef.current.add(activePopup.notification_id);
77
+ markSeen([activePopup.markId]);
78
+ }, [activePopup, onboardingActive, markSeen]);
79
+
80
+ const dismissActivePopup = useCallback(() => {
81
+ if (!activePopup) return;
82
+ if (activePopup.markId != null) markDismissed([activePopup.markId]);
83
+ setQueue((previous) => previous.filter((item) => item.notification_id !== activePopup.notification_id));
84
+ }, [activePopup, markDismissed]);
85
+
86
+ if (!activePopup || onboardingActive) return null;
87
+
88
+ return (
89
+ <WizardDialogShell
90
+ step={POPUP_STEP}
91
+ stepIndex={0}
92
+ total={1}
93
+ onComplete={dismissActivePopup}
94
+ onDismiss={dismissActivePopup}
95
+ ctx={{ content: activePopup.content }}
96
+ title={activePopup.content.title_key
97
+ ? t(activePopup.content.title_key, activePopup.content.params || {})
98
+ : t('PopupSurface.TITLE')}
99
+ />
100
+ );
101
+ }
102
+
103
+ export default PopupSurface;
@@ -0,0 +1,118 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef } from 'react';
2
+
3
+ const INITIAL_BACKOFF_MS = 1_000;
4
+ const MAX_BACKOFF_MS = 30_000;
5
+
6
+ // Envelope discriminator for the notifications domain. A message with no
7
+ // `envelope` field (pre-NOTIF-13 dcm) is treated as this default so an
8
+ // un-bumped backend keeps working unchanged (backward-compat).
9
+ export const DEFAULT_ENVELOPE = 'notification';
10
+
11
+ const RealtimeContext = createContext(null);
12
+
13
+ export function useRealtime() {
14
+ const context = useContext(RealtimeContext);
15
+ if (!context) {
16
+ throw new Error('useRealtime must be used within a NotificationsProvider');
17
+ }
18
+ return context;
19
+ }
20
+
21
+ export { RealtimeContext };
22
+
23
+ /**
24
+ * Owns the single WebSocket connection and an envelope-keyed subscriber
25
+ * registry. Internal — consumed only by NotificationsProvider, which
26
+ * re-exposes `subscribe` via RealtimeContext so useRealtime() works without
27
+ * a separate provider apps have to mount.
28
+ *
29
+ * Backoff, reconnect, and cleanup semantics are unchanged from the prior
30
+ * single-purpose socket owner. `active`/`wsUrl` are primitives, so the
31
+ * effect does not re-run (and does not reconnect) on unrelated re-renders.
32
+ */
33
+ export function useRealtimeCore({ active, wsUrl }) {
34
+ const socketRef = useRef(null);
35
+ const subscribersRef = useRef(new Map());
36
+
37
+ const subscribe = useCallback((envelope, handler) => {
38
+ const subscribers = subscribersRef.current;
39
+ if (!subscribers.has(envelope)) {
40
+ subscribers.set(envelope, new Set());
41
+ }
42
+ subscribers.get(envelope).add(handler);
43
+ return () => {
44
+ const handlers = subscribers.get(envelope);
45
+ if (!handlers) return;
46
+ handlers.delete(handler);
47
+ if (handlers.size === 0) {
48
+ subscribers.delete(envelope);
49
+ }
50
+ };
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ if (!active) return undefined;
55
+
56
+ let isActive = true;
57
+ let reconnectTimer = null;
58
+ let backoffMs = INITIAL_BACKOFF_MS;
59
+
60
+ const handleMessage = (event) => {
61
+ let data;
62
+ try {
63
+ data = JSON.parse(event.data);
64
+ } catch {
65
+ return;
66
+ }
67
+
68
+ const envelope = data.envelope ?? DEFAULT_ENVELOPE;
69
+ const handlers = subscribersRef.current.get(envelope);
70
+ if (!handlers || handlers.size === 0) return;
71
+ handlers.forEach((handler) => {
72
+ try {
73
+ handler(data);
74
+ } catch (error) {
75
+ console.error('Realtime subscriber error', error);
76
+ }
77
+ });
78
+ };
79
+
80
+ const connect = () => {
81
+ if (!isActive || socketRef.current) return;
82
+
83
+ const socket = new WebSocket(wsUrl);
84
+ socketRef.current = socket;
85
+
86
+ socket.onopen = () => {
87
+ backoffMs = INITIAL_BACKOFF_MS;
88
+ };
89
+ socket.onmessage = handleMessage;
90
+ socket.onerror = () => {
91
+ socket.close();
92
+ };
93
+ socket.onclose = () => {
94
+ if (socketRef.current === socket) {
95
+ socketRef.current = null;
96
+ }
97
+ if (!isActive || reconnectTimer) return;
98
+ reconnectTimer = setTimeout(() => {
99
+ reconnectTimer = null;
100
+ connect();
101
+ }, backoffMs);
102
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
103
+ };
104
+ };
105
+
106
+ connect();
107
+
108
+ return () => {
109
+ isActive = false;
110
+ clearTimeout(reconnectTimer);
111
+ const socket = socketRef.current;
112
+ socketRef.current = null;
113
+ if (socket) socket.close();
114
+ };
115
+ }, [active, wsUrl]);
116
+
117
+ return { subscribe };
118
+ }
@@ -1,16 +1,9 @@
1
1
  import React, { useContext, useEffect, useRef, useState } from 'react';
2
- import { useTranslation } from 'react-i18next';
3
- import Box from '@mui/material/Box';
4
- import Dialog from '@mui/material/Dialog';
5
- import DialogContent from '@mui/material/DialogContent';
6
- import DialogTitle from '@mui/material/DialogTitle';
7
- import LinearProgress from '@mui/material/LinearProgress';
8
- import Typography from '@mui/material/Typography';
9
2
  import { AuthContext } from '../auth/AuthContext';
10
3
  import { useOnboarding } from './OnboardingProvider';
4
+ import { WizardDialogShell } from './WizardDialogShell';
11
5
 
12
6
  export function OnboardingWizard() {
13
- const { t } = useTranslation();
14
7
  const onboarding = useOnboarding();
15
8
  const authContext = useContext(AuthContext);
16
9
  const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
@@ -39,8 +32,6 @@ export function OnboardingWizard() {
39
32
 
40
33
  const { dismissStep, ctx } = onboarding;
41
34
  const total = totalRef.current || visibleSteps.length;
42
- const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
43
- const StepComponent = currentStep.Component;
44
35
 
45
36
  const completeCurrentStep = () => {
46
37
  setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
@@ -56,19 +47,14 @@ export function OnboardingWizard() {
56
47
  };
57
48
 
58
49
  return (
59
- <Dialog open fullWidth maxWidth="sm" disableEscapeKeyDown={currentStep.blocking} onClose={currentStep.blocking ? undefined : dismissCurrentStep}>
60
- <DialogTitle sx={{ pb: 0 }}>
61
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
62
- <Typography variant="subtitle2" color="text.secondary">
63
- {total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP')}
64
- </Typography>
65
- </Box>
66
- {total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
67
- </DialogTitle>
68
- <DialogContent sx={{ pt: 2 }}>
69
- <StepComponent onComplete={completeCurrentStep} onDismiss={dismissCurrentStep} ctx={ctx} />
70
- </DialogContent>
71
- </Dialog>
50
+ <WizardDialogShell
51
+ step={currentStep}
52
+ stepIndex={completed}
53
+ total={total}
54
+ onComplete={completeCurrentStep}
55
+ onDismiss={dismissCurrentStep}
56
+ ctx={ctx}
57
+ />
72
58
  );
73
59
  }
74
60
 
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Box from '@mui/material/Box';
4
+ import Dialog from '@mui/material/Dialog';
5
+ import DialogContent from '@mui/material/DialogContent';
6
+ import DialogTitle from '@mui/material/DialogTitle';
7
+ import LinearProgress from '@mui/material/LinearProgress';
8
+ import Typography from '@mui/material/Typography';
9
+
10
+ /**
11
+ * Presentational dialog shell shared by OnboardingWizard and the popup
12
+ * notification surface (NOTIF-12). Owns only the dialog frame, the
13
+ * counter/progress header, and the onComplete/onDismiss/ctx child contract —
14
+ * step selection, dismissal persistence, and any progress store stay with
15
+ * the caller.
16
+ */
17
+ export function WizardDialogShell({ step, stepIndex, total, onComplete, onDismiss, ctx, title }) {
18
+ const { t } = useTranslation();
19
+ const progress = total > 1 ? Math.round((stepIndex / total) * 100) : 0;
20
+ const StepComponent = step.Component;
21
+ // `title` lets a non-onboarding caller (e.g. the popup surface) supply its
22
+ // own header instead of the onboarding-specific step counter/"Setup" copy.
23
+ // Omitted by OnboardingWizard, so its header text is unchanged.
24
+ const headerLabel = title ?? (total > 1
25
+ ? t('Onboarding.STEP_COUNTER', { current: stepIndex + 1, total })
26
+ : t('Onboarding.SETUP'));
27
+
28
+ return (
29
+ <Dialog
30
+ open
31
+ fullWidth
32
+ maxWidth="sm"
33
+ disableEscapeKeyDown={step.blocking}
34
+ onClose={step.blocking ? undefined : onDismiss}
35
+ >
36
+ <DialogTitle sx={{ pb: 0 }}>
37
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
38
+ <Typography variant="subtitle2" color="text.secondary">
39
+ {headerLabel}
40
+ </Typography>
41
+ </Box>
42
+ {total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
43
+ </DialogTitle>
44
+ <DialogContent sx={{ pt: 2 }}>
45
+ <StepComponent onComplete={onComplete} onDismiss={onDismiss} ctx={ctx} />
46
+ </DialogContent>
47
+ </Dialog>
48
+ );
49
+ }
50
+
51
+ export default WizardDialogShell;
@@ -136,6 +136,7 @@ describe('NotificationsProvider', () => {
136
136
  it('decrements unread count on a cross-tab status push and does not double-count a repeat push', async () => {
137
137
  renderProvider();
138
138
  await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
139
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
139
140
 
140
141
  MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
141
142
  type: 'notification.status',
@@ -194,4 +195,42 @@ describe('NotificationsProvider', () => {
194
195
  expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true }));
195
196
  expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'done', ids: [1] });
196
197
  });
198
+
199
+ it('folds two WS messages for the same notification_id into one feed entry with a single unread increment (NOTIF-12 D2)', async () => {
200
+ renderProvider();
201
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
202
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
203
+
204
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
205
+ type: 'payment_due',
206
+ channel: 'chip',
207
+ notification_id: 404,
208
+ content: { title_key: 'chip.title' },
209
+ }) });
210
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('3'));
211
+
212
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
213
+ type: 'payment_due',
214
+ channel: 'popup',
215
+ notification_id: 404,
216
+ content: { title_key: 'popup.title' },
217
+ }) });
218
+
219
+ await waitFor(() => expect(currentNotifications().find((n) => n.notification_id === 404).content)
220
+ .toEqual({ title_key: 'popup.title' }));
221
+ expect(currentNotifications().filter((n) => n.notification_id === 404)).toHaveLength(1);
222
+ expect(screen.getByTestId('unread-count').textContent).toBe('3');
223
+ });
224
+
225
+ it('NOTIF-13 R1 guard: does not open the socket until the REST seed resolves', async () => {
226
+ let resolveFeed;
227
+ feedApi.getNotificationFeed.mockReturnValue(new Promise((resolve) => { resolveFeed = resolve; }));
228
+
229
+ renderProvider();
230
+
231
+ expect(MockWebSocket.instances).toHaveLength(0);
232
+
233
+ resolveFeed({ results: [] });
234
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
235
+ });
197
236
  });