@micha.bigler/ui-core-micha 2.10.1 → 2.12.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,84 @@
1
+ import { useState } from 'react';
2
+
3
+ import NotificationsIcon from '@mui/icons-material/Notifications';
4
+ import {
5
+ Badge,
6
+ Box,
7
+ Button,
8
+ IconButton,
9
+ List,
10
+ ListItemButton,
11
+ ListItemText,
12
+ Menu,
13
+ Typography,
14
+ } from '@mui/material';
15
+ import { useTranslation } from 'react-i18next';
16
+
17
+ import { useNotifications } from './NotificationsProvider';
18
+
19
+ export function NotificationBell({ resolveLink }) {
20
+ const { t } = useTranslation();
21
+ const { notifications, unreadCount, markSeen } = useNotifications();
22
+ const [anchorEl, setAnchorEl] = useState(null);
23
+ const open = Boolean(anchorEl);
24
+ const unreadIds = notifications
25
+ .filter((notification) => !notification.seen && !notification.dismissed && notification.id != null)
26
+ .map((notification) => notification.id);
27
+
28
+ const handleItemClick = (notification) => {
29
+ if (notification.id != null) markSeen([notification.id]);
30
+ setAnchorEl(null);
31
+ if (resolveLink && notification.content?.link) resolveLink(notification.content.link);
32
+ };
33
+
34
+ return (
35
+ <>
36
+ <IconButton
37
+ aria-label={t('NotificationBell.TITLE')}
38
+ color="inherit"
39
+ onClick={(event) => setAnchorEl(event.currentTarget)}
40
+ >
41
+ <Badge badgeContent={unreadCount} color="error" max={99}>
42
+ <NotificationsIcon />
43
+ </Badge>
44
+ </IconButton>
45
+ <Menu
46
+ anchorEl={anchorEl}
47
+ anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
48
+ onClose={() => setAnchorEl(null)}
49
+ open={open}
50
+ transformOrigin={{ horizontal: 'right', vertical: 'top' }}
51
+ >
52
+ <Box sx={{ minWidth: 320 }}>
53
+ <Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'space-between', px: 2, py: 1 }}>
54
+ <Typography variant="subtitle1">{t('NotificationBell.TITLE')}</Typography>
55
+ {unreadIds.length > 0 && (
56
+ <Button onClick={() => markSeen(unreadIds)} size="small">
57
+ {t('NotificationBell.MARK_ALL')}
58
+ </Button>
59
+ )}
60
+ </Box>
61
+ {notifications.length === 0 ? (
62
+ <Typography sx={{ px: 2, py: 2 }} variant="body2">
63
+ {t('NotificationBell.EMPTY')}
64
+ </Typography>
65
+ ) : (
66
+ <List dense disablePadding>
67
+ {notifications.map((notification) => (
68
+ <ListItemButton key={notification.id ?? notification.notification_id} onClick={() => handleItemClick(notification)}>
69
+ <ListItemText
70
+ primary={t(notification.content?.title_key, notification.content?.params || {})}
71
+ secondary={notification.content?.body_key
72
+ ? t(notification.content.body_key, notification.content.params || {})
73
+ : undefined}
74
+ slotProps={{ primary: { fontWeight: notification.seen || notification.dismissed ? 'regular' : 'bold' } }}
75
+ />
76
+ </ListItemButton>
77
+ ))}
78
+ </List>
79
+ )}
80
+ </Box>
81
+ </Menu>
82
+ </>
83
+ );
84
+ }
@@ -0,0 +1,232 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
2
+
3
+ import { AuthContext } from '../auth/AuthContext';
4
+ import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
5
+
6
+ const INITIAL_BACKOFF_MS = 1_000;
7
+ const MAX_BACKOFF_MS = 30_000;
8
+
9
+ const NotificationsContext = createContext(null);
10
+
11
+ function normalizeNotification(notification) {
12
+ return {
13
+ ...notification,
14
+ content: notification.content || {},
15
+ seen: Boolean(notification.seen ?? notification.seen_at),
16
+ dismissed: Boolean(notification.dismissed ?? notification.dismissed_at),
17
+ done: Boolean(notification.done ?? notification.done_at),
18
+ };
19
+ }
20
+
21
+ function normalizeNotificationPush(data) {
22
+ return normalizeNotification({
23
+ id: data.id ?? data.recipient_id ?? data.notification_id,
24
+ notification_id: data.notification_id,
25
+ notification_type: data.type,
26
+ content: data.content || {},
27
+ created_at: data.created_at,
28
+ seen_at: null,
29
+ dismissed_at: null,
30
+ done_at: null,
31
+ });
32
+ }
33
+
34
+ function getWebSocketUrl(wsUrlBase) {
35
+ if (wsUrlBase) {
36
+ return new URL('/ws/notifications/', wsUrlBase).toString();
37
+ }
38
+
39
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ return `${protocol}//${window.location.host}/ws/notifications/`;
41
+ }
42
+
43
+ function patchStatus(notification, status) {
44
+ const seen = Boolean(status.seen);
45
+ const dismissed = Boolean(status.dismissed);
46
+ const done = Boolean(status.done);
47
+
48
+ return {
49
+ ...notification,
50
+ seen,
51
+ dismissed,
52
+ done,
53
+ seen_at: seen ? notification.seen_at || true : null,
54
+ dismissed_at: dismissed ? notification.dismissed_at || true : null,
55
+ done_at: done ? notification.done_at || true : null,
56
+ };
57
+ }
58
+
59
+ export function NotificationsProvider({ children, wsUrlBase }) {
60
+ const auth = useContext(AuthContext);
61
+ const authenticated = Boolean(auth?.user);
62
+ const [notifications, setNotifications] = useState([]);
63
+ const [unreadCount, setUnreadCount] = useState(0);
64
+ const notificationsRef = useRef([]);
65
+ const socketRef = useRef(null);
66
+
67
+ const replaceNotifications = useCallback((nextNotifications) => {
68
+ notificationsRef.current = nextNotifications;
69
+ setNotifications(nextNotifications);
70
+ }, []);
71
+
72
+ const refresh = useCallback(async () => {
73
+ if (!authenticated) {
74
+ replaceNotifications([]);
75
+ setUnreadCount(0);
76
+ return { notifications: [], unreadCount: 0 };
77
+ }
78
+
79
+ const [feed, unread] = await Promise.all([getNotificationFeed(), getUnreadCount()]);
80
+ const nextNotifications = (feed.results || []).map(normalizeNotification);
81
+ const nextUnreadCount = unread.count || 0;
82
+ replaceNotifications(nextNotifications);
83
+ setUnreadCount(nextUnreadCount);
84
+ return { notifications: nextNotifications, unreadCount: nextUnreadCount };
85
+ }, [authenticated, replaceNotifications]);
86
+
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;
97
+
98
+ const patchNotifications = (updater) => {
99
+ const nextNotifications = updater(notificationsRef.current);
100
+ replaceNotifications(nextNotifications);
101
+ };
102
+
103
+ const handleMessage = (event) => {
104
+ let data;
105
+ try {
106
+ data = JSON.parse(event.data);
107
+ } catch {
108
+ return;
109
+ }
110
+
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
+ }
129
+
130
+ const pushedNotification = normalizeNotificationPush(data);
131
+ patchNotifications((current) => [pushedNotification, ...current]);
132
+ setUnreadCount((count) => count + 1);
133
+ };
134
+
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
+ };
160
+
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
+ };
169
+
170
+ seedAndConnect();
171
+
172
+ return () => {
173
+ active = false;
174
+ clearTimeout(reconnectTimer);
175
+ const socket = socketRef.current;
176
+ socketRef.current = null;
177
+ if (socket) socket.close();
178
+ };
179
+ }, [authenticated, refresh, replaceNotifications, wsUrlBase]);
180
+
181
+ const mark = useCallback((action, ids) => {
182
+ const selectedIds = Array.isArray(ids) ? ids : [];
183
+ const selected = new Set(selectedIds);
184
+ const current = notificationsRef.current;
185
+ let unreadDelta = 0;
186
+ const nextNotifications = current.map((notification) => {
187
+ if (!selected.has(notification.id)) return notification;
188
+
189
+ const wasUnread = !notification.seen && !notification.dismissed;
190
+ if ((action === 'seen' || action === 'dismissed') && wasUnread) {
191
+ unreadDelta += 1;
192
+ }
193
+
194
+ return patchStatus(notification, {
195
+ seen: action === 'seen' ? true : notification.seen,
196
+ dismissed: action === 'dismissed' ? true : notification.dismissed,
197
+ done: action === 'done' ? true : notification.done,
198
+ });
199
+ });
200
+
201
+ replaceNotifications(nextNotifications);
202
+ if (unreadDelta) {
203
+ setUnreadCount((count) => Math.max(0, count - unreadDelta));
204
+ }
205
+ return markNotifications({ action, ids: selectedIds });
206
+ }, [replaceNotifications]);
207
+
208
+ const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
209
+ const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
210
+ const markDone = useCallback((ids) => mark('done', ids), [mark]);
211
+
212
+ return (
213
+ <NotificationsContext.Provider value={{
214
+ notifications,
215
+ unreadCount,
216
+ markSeen,
217
+ markDismissed,
218
+ markDone,
219
+ refresh,
220
+ }}>
221
+ {children}
222
+ </NotificationsContext.Provider>
223
+ );
224
+ }
225
+
226
+ export function useNotifications() {
227
+ const context = useContext(NotificationsContext);
228
+ if (!context) {
229
+ throw new Error('useNotifications must be used within a NotificationsProvider');
230
+ }
231
+ return context;
232
+ }
@@ -0,0 +1,18 @@
1
+ import apiClient from '../auth/apiClient';
2
+
3
+ export async function getNotificationFeed({ status } = {}) {
4
+ const response = await apiClient.get('/api/notifications/feed/', {
5
+ params: status ? { status } : undefined,
6
+ });
7
+ return response.data;
8
+ }
9
+
10
+ export async function getUnreadCount() {
11
+ const response = await apiClient.get('/api/notifications/feed/unread-count/');
12
+ return response.data;
13
+ }
14
+
15
+ export async function markNotifications({ action, ids }) {
16
+ const response = await apiClient.post('/api/notifications/feed/mark/', { action, ids });
17
+ return response.data;
18
+ }
@@ -1,4 +1,10 @@
1
- import React, { useContext, useEffect, useMemo, useState } from 'react';
1
+ import React, {
2
+ useContext,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
  import { useNavigate, useLocation } from 'react-router-dom';
3
9
  import {
4
10
  Alert,
@@ -13,6 +19,12 @@ import { useTranslation } from 'react-i18next';
13
19
  import { NarrowPage } from '../layout/PageLayout';
14
20
  import { AuthContext } from '../auth/AuthContext';
15
21
  import { submitRegistrationRequest } from '../auth/authApi';
22
+ import { TurnstileWidget } from '../components/TurnstileWidget';
23
+
24
+ const TURNSTILE_SIGNUP_MODES = new Set([
25
+ 'self_signup_open',
26
+ 'self_signup_email_domain',
27
+ ]);
16
28
 
17
29
  const MODE_LABELS = {
18
30
  self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_TAB',
@@ -60,6 +72,11 @@ export function SignUpPage() {
60
72
  const [submitting, setSubmitting] = useState(false);
61
73
  const [successKey, setSuccessKey] = useState(null);
62
74
  const [errorKey, setErrorKey] = useState(null);
75
+ const [turnstileToken, setTurnstileToken] = useState('');
76
+ const turnstileWidgetRef = useRef(null);
77
+
78
+ const turnstileRequired = Boolean(authMethods?.turnstile_site_key)
79
+ && TURNSTILE_SIGNUP_MODES.has(mode);
63
80
 
64
81
  const pageSubtitle = useMemo(() => {
65
82
  if (mode === 'self_signup_qr' && tokenFromUrl) {
@@ -78,6 +95,10 @@ export function SignUpPage() {
78
95
  setMode(initialMode);
79
96
  }, [initialMode]);
80
97
 
98
+ useEffect(() => {
99
+ setTurnstileToken('');
100
+ }, [mode, authMethods?.turnstile_site_key]);
101
+
81
102
  const handleSubmit = async (event) => {
82
103
  event.preventDefault();
83
104
  setSuccessKey(null);
@@ -98,6 +119,8 @@ export function SignUpPage() {
98
119
  return;
99
120
  }
100
121
 
122
+ if (turnstileRequired && !turnstileToken) return;
123
+
101
124
  setSubmitting(true);
102
125
  try {
103
126
  await submitRegistrationRequest({
@@ -105,10 +128,15 @@ export function SignUpPage() {
105
128
  mode,
106
129
  accessCode,
107
130
  registrationContextToken: mode === 'self_signup_qr' ? tokenFromUrl : null,
131
+ turnstileToken: turnstileRequired ? turnstileToken : undefined,
108
132
  });
109
133
  setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
110
134
  } catch (err) {
111
135
  setErrorKey(err.code || 'Auth.INVITE_FAILED');
136
+ if (turnstileRequired) {
137
+ setTurnstileToken('');
138
+ turnstileWidgetRef.current?.reset();
139
+ }
112
140
  } finally {
113
141
  setSubmitting(false);
114
142
  }
@@ -186,10 +214,18 @@ export function SignUpPage() {
186
214
  />
187
215
  )}
188
216
 
217
+ {turnstileRequired && (
218
+ <TurnstileWidget
219
+ ref={turnstileWidgetRef}
220
+ siteKey={authMethods.turnstile_site_key}
221
+ onToken={setTurnstileToken}
222
+ />
223
+ )}
224
+
189
225
  <Button
190
226
  type="submit"
191
227
  variant="contained"
192
- disabled={submitting || signupModes.length === 0}
228
+ disabled={submitting || signupModes.length === 0 || (turnstileRequired && !turnstileToken)}
193
229
  >
194
230
  {submitting
195
231
  ? t('Auth.SIGNUP_SUBMITTING')
@@ -0,0 +1,60 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ const notifications = vi.hoisted(() => ({
7
+ useNotifications: vi.fn(),
8
+ }));
9
+ const translation = vi.hoisted(() => ({
10
+ t: vi.fn((key) => `translated:${key}`),
11
+ }));
12
+
13
+ vi.mock('../src/notifications/NotificationsProvider', () => notifications);
14
+ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
15
+
16
+ import { NotificationBell } from '../src/notifications/NotificationBell';
17
+
18
+ describe('NotificationBell', () => {
19
+ afterEach(() => {
20
+ cleanup();
21
+ });
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ notifications.useNotifications.mockReturnValue({
26
+ unreadCount: 2,
27
+ markSeen: vi.fn(),
28
+ notifications: [{
29
+ id: 7,
30
+ notification_id: 70,
31
+ seen: false,
32
+ dismissed: false,
33
+ content: {
34
+ title_key: 'domain.notification.title',
35
+ body_key: 'domain.notification.body',
36
+ params: { name: 'Ada' },
37
+ link: '/payments/7',
38
+ },
39
+ }],
40
+ });
41
+ });
42
+
43
+ it('renders the unread badge, translates item content, and handles item navigation', () => {
44
+ const resolveLink = vi.fn();
45
+ render(<NotificationBell resolveLink={resolveLink} />);
46
+
47
+ expect(screen.getByText('2')).toBeTruthy();
48
+ fireEvent.click(screen.getByRole('button', { name: 'translated:NotificationBell.TITLE' }));
49
+
50
+ expect(screen.getByText('translated:domain.notification.title')).toBeTruthy();
51
+ expect(screen.getByText('translated:domain.notification.body')).toBeTruthy();
52
+ expect(translation.t).toHaveBeenCalledWith('domain.notification.title', { name: 'Ada' });
53
+ expect(translation.t).toHaveBeenCalledWith('domain.notification.body', { name: 'Ada' });
54
+
55
+ fireEvent.click(screen.getByText('translated:domain.notification.title'));
56
+
57
+ expect(notifications.useNotifications.mock.results[0].value.markSeen).toHaveBeenCalledWith([7]);
58
+ expect(resolveLink).toHaveBeenCalledWith('/payments/7');
59
+ });
60
+ });
@@ -0,0 +1,197 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const feedApi = vi.hoisted(() => ({
7
+ getNotificationFeed: vi.fn(),
8
+ getUnreadCount: vi.fn(),
9
+ markNotifications: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/notifications/feedApi', () => feedApi);
13
+
14
+ import { AuthContext } from '../src/auth/AuthContext';
15
+ import { NotificationsProvider, useNotifications } from '../src/notifications/NotificationsProvider';
16
+
17
+ class MockWebSocket {
18
+ static instances = [];
19
+
20
+ constructor(url) {
21
+ this.url = url;
22
+ this.close = vi.fn(() => this.onclose?.());
23
+ MockWebSocket.instances.push(this);
24
+ }
25
+ }
26
+
27
+ const firstNotification = {
28
+ id: 1,
29
+ notification_id: 101,
30
+ content: { title_key: 'first.title' },
31
+ seen_at: null,
32
+ dismissed_at: null,
33
+ done_at: null,
34
+ };
35
+
36
+ const secondNotification = {
37
+ id: 2,
38
+ notification_id: 102,
39
+ content: { title_key: 'second.title' },
40
+ seen_at: null,
41
+ dismissed_at: null,
42
+ done_at: null,
43
+ };
44
+
45
+ function Consumer() {
46
+ const notifications = useNotifications();
47
+ return (
48
+ <>
49
+ <output data-testid="notifications">{JSON.stringify(notifications.notifications)}</output>
50
+ <output data-testid="unread-count">{notifications.unreadCount}</output>
51
+ <button onClick={() => notifications.markSeen([1])}>seen</button>
52
+ <button onClick={() => notifications.markDismissed([2])}>dismissed</button>
53
+ <button onClick={() => notifications.markDone([1])}>done</button>
54
+ </>
55
+ );
56
+ }
57
+
58
+ function renderProvider({ children = <Consumer />, user = { id: 4 }, ...providerProps } = {}) {
59
+ return render(
60
+ <AuthContext.Provider value={{ user }}>
61
+ <NotificationsProvider {...providerProps}>{children}</NotificationsProvider>
62
+ </AuthContext.Provider>,
63
+ );
64
+ }
65
+
66
+ function currentNotifications() {
67
+ return JSON.parse(screen.getByTestId('notifications').textContent);
68
+ }
69
+
70
+ describe('NotificationsProvider', () => {
71
+ afterEach(() => {
72
+ cleanup();
73
+ });
74
+
75
+ beforeEach(() => {
76
+ vi.clearAllMocks();
77
+ MockWebSocket.instances = [];
78
+ vi.stubGlobal('WebSocket', MockWebSocket);
79
+ feedApi.getNotificationFeed.mockResolvedValue({ results: [firstNotification, secondNotification] });
80
+ feedApi.getUnreadCount.mockResolvedValue({ count: 2 });
81
+ feedApi.markNotifications.mockResolvedValue({ updated: 1 });
82
+ });
83
+
84
+ it('seeds state from the feed and unread-count endpoints', async () => {
85
+ renderProvider();
86
+
87
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
88
+
89
+ expect(currentNotifications()).toEqual(expect.arrayContaining([
90
+ expect.objectContaining({ id: 1, notification_id: 101, seen: false }),
91
+ expect.objectContaining({ id: 2, notification_id: 102, dismissed: false }),
92
+ ]));
93
+ expect(MockWebSocket.instances).toHaveLength(1);
94
+ });
95
+
96
+ it('prepends a new notification push and increments the unread count', async () => {
97
+ renderProvider();
98
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
99
+
100
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
101
+ type: 'payment_due',
102
+ notification_id: 303,
103
+ content: { title_key: 'payment.title', body_key: 'payment.body', params: { amount: 20 } },
104
+ }) });
105
+
106
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('3'));
107
+ expect(currentNotifications()[0]).toEqual(expect.objectContaining({
108
+ id: 303,
109
+ notification_id: 303,
110
+ notification_type: 'payment_due',
111
+ seen: false,
112
+ }));
113
+ });
114
+
115
+ it('applies a status push to the matching notification without duplication', async () => {
116
+ renderProvider();
117
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
118
+
119
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
120
+ type: 'notification.status',
121
+ notification_id: 101,
122
+ status: { seen: true, dismissed: false, done: true },
123
+ }) });
124
+
125
+ await waitFor(() => expect(currentNotifications()[0]).toEqual(expect.objectContaining({
126
+ notification_id: 101,
127
+ seen: true,
128
+ dismissed: false,
129
+ done: true,
130
+ })));
131
+ expect(currentNotifications()).toHaveLength(2);
132
+ expect(currentNotifications()[1]).toEqual(expect.objectContaining({ notification_id: 102, seen: false }));
133
+ expect(screen.getByTestId('unread-count').textContent).toBe('1');
134
+ });
135
+
136
+ it('decrements unread count on a cross-tab status push and does not double-count a repeat push', async () => {
137
+ renderProvider();
138
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
139
+
140
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
141
+ type: 'notification.status',
142
+ notification_id: 101,
143
+ status: { seen: true, dismissed: false, done: false },
144
+ }) });
145
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('1'));
146
+
147
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
148
+ type: 'notification.status',
149
+ notification_id: 101,
150
+ status: { seen: true, dismissed: false, done: true },
151
+ }) });
152
+ await waitFor(() => expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true })));
153
+ expect(screen.getByTestId('unread-count').textContent).toBe('1');
154
+ });
155
+
156
+ it('constructs only one WebSocket across a provider re-render', async () => {
157
+ const { rerender } = renderProvider();
158
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
159
+
160
+ rerender(
161
+ <AuthContext.Provider value={{ user: { id: 4 } }}>
162
+ <NotificationsProvider><Consumer /></NotificationsProvider>
163
+ </AuthContext.Provider>,
164
+ );
165
+
166
+ expect(MockWebSocket.instances).toHaveLength(1);
167
+ });
168
+
169
+ it('closes the socket on unmount', async () => {
170
+ const { unmount } = renderProvider();
171
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
172
+ const socket = MockWebSocket.instances[0];
173
+
174
+ unmount();
175
+
176
+ expect(socket.close).toHaveBeenCalledOnce();
177
+ });
178
+
179
+ it('marks rows optimistically and sends each canonical action', async () => {
180
+ renderProvider();
181
+ await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
182
+
183
+ fireEvent.click(screen.getByRole('button', { name: 'seen' }));
184
+ expect(currentNotifications()[0]).toEqual(expect.objectContaining({ seen: true }));
185
+ expect(screen.getByTestId('unread-count').textContent).toBe('1');
186
+ expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'seen', ids: [1] });
187
+
188
+ fireEvent.click(screen.getByRole('button', { name: 'dismissed' }));
189
+ expect(currentNotifications()[1]).toEqual(expect.objectContaining({ dismissed: true }));
190
+ expect(screen.getByTestId('unread-count').textContent).toBe('0');
191
+ expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'dismissed', ids: [2] });
192
+
193
+ fireEvent.click(screen.getByRole('button', { name: 'done' }));
194
+ expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true }));
195
+ expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'done', ids: [1] });
196
+ });
197
+ });