refactor: move notification domain to lib/notification/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
121
frontend/src/lib/notification/NotificationBell.svelte
Normal file
121
frontend/src/lib/notification/NotificationBell.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
||||
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let bellButtonEl: HTMLButtonElement | null = null;
|
||||
|
||||
const stream = notificationStore;
|
||||
|
||||
async function toggleDropdown() {
|
||||
open = !open;
|
||||
if (open) {
|
||||
await stream.fetchNotifications();
|
||||
setTimeout(() => {
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(
|
||||
'[role="dialog"] button, [role="dialog"] a'
|
||||
);
|
||||
firstBtn?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
open = false;
|
||||
bellButtonEl?.focus();
|
||||
}
|
||||
|
||||
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
||||
await stream.markRead(notification);
|
||||
const url = buildCommentHref(
|
||||
notification.documentId,
|
||||
notification.referenceId,
|
||||
notification.annotationId
|
||||
);
|
||||
closeDropdown();
|
||||
goto(url);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
const bellLabel = $derived(
|
||||
stream.unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||
: m.notification_bell_label()
|
||||
);
|
||||
|
||||
function attachBellButton(node: HTMLButtonElement) {
|
||||
bellButtonEl = node;
|
||||
return () => {
|
||||
bellButtonEl = null;
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
stream.init();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stream.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
|
||||
<!-- Bell button -->
|
||||
<button
|
||||
{@attach attachBellButton}
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
aria-label={bellLabel}
|
||||
title={bellLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
|
||||
<span
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg {stream.unreadCount > 0 ? '' : 'hidden'}"
|
||||
>
|
||||
{stream.unreadCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<NotificationDropdown
|
||||
notifications={stream.notifications}
|
||||
onMarkRead={handleMarkRead}
|
||||
onMarkAllRead={stream.markAllRead}
|
||||
onClose={closeDropdown}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
110
frontend/src/lib/notification/NotificationBell.svelte.spec.ts
Normal file
110
frontend/src/lib/notification/NotificationBell.svelte.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
|
||||
const gotoMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
||||
|
||||
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||
|
||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
notificationStore: {
|
||||
get notifications() {
|
||||
return mockNotificationList.value;
|
||||
},
|
||||
get unreadCount() {
|
||||
return mockNotificationList.value.length;
|
||||
},
|
||||
markRead: mockMarkRead,
|
||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
markAllRead: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
gotoMock.mockClear();
|
||||
mockMarkRead.mockClear();
|
||||
mockNotificationList.value = [];
|
||||
});
|
||||
|
||||
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 'n1',
|
||||
type: 'REPLY',
|
||||
documentId: 'doc-1',
|
||||
referenceId: 'ref-1',
|
||||
annotationId: null,
|
||||
read: false,
|
||||
createdAt: '2026-04-21T10:00:00Z',
|
||||
actorName: 'Anna',
|
||||
documentTitle: 'Test Doc',
|
||||
...overrides
|
||||
});
|
||||
|
||||
async function openDropdownAndClickFirstNotification() {
|
||||
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
bellButton.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
});
|
||||
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
|
||||
notifButton.click();
|
||||
}
|
||||
|
||||
describe('NotificationBell — cursor and tooltip', () => {
|
||||
it('bell button has cursor-pointer class', async () => {
|
||||
render(NotificationBell);
|
||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||
});
|
||||
|
||||
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||
mockNotificationList.value = [];
|
||||
render(NotificationBell);
|
||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||
});
|
||||
|
||||
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||
mockNotificationList.value = [
|
||||
makeNotification({ id: 'n1' }),
|
||||
makeNotification({ id: 'n2' }),
|
||||
makeNotification({ id: 'n3' })
|
||||
];
|
||||
render(NotificationBell);
|
||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||
render(NotificationBell);
|
||||
|
||||
await openDropdownAndClickFirstNotification();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(gotoMock).toHaveBeenCalledWith(
|
||||
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
|
||||
mockNotificationList.value = [makeNotification({ annotationId: null })];
|
||||
render(NotificationBell);
|
||||
|
||||
await openDropdownAndClickFirstNotification();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
138
frontend/src/lib/notification/NotificationDropdown.svelte
Normal file
138
frontend/src/lib/notification/NotificationDropdown.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
|
||||
type Props = {
|
||||
notifications: NotificationItem[];
|
||||
onMarkRead: (notification: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.notification_bell_label()}
|
||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.notification_bell_label()}
|
||||
</span>
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMarkAllRead}
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notification list -->
|
||||
{#if notifications.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-ink-3 opacity-40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<span>{m.notification_empty()}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onMarkRead(notification)}
|
||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Text + time -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-snug text-ink">
|
||||
{notification.type === 'REPLY'
|
||||
? m.notification_type_reply({ actor: notification.actorName })
|
||||
: m.notification_type_mention({ actor: notification.actorName })}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Unread dot -->
|
||||
{#if !notification.read}
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-label={m.notification_unread()}
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-line px-4 py-2">
|
||||
<a
|
||||
href="/aktivitaeten"
|
||||
onclick={onClose}
|
||||
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.chronik_view_all()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
55
frontend/src/lib/notification/notifications.spec.ts
Normal file
55
frontend/src/lib/notification/notifications.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseNotificationEvent } from '$lib/notification/notifications';
|
||||
|
||||
describe('parseNotificationEvent', () => {
|
||||
const valid = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
documentId: '00000000-0000-0000-0000-000000000002',
|
||||
actorName: 'Anna Müller',
|
||||
type: 'MENTION',
|
||||
referenceId: '00000000-0000-0000-0000-000000000003',
|
||||
annotationId: null,
|
||||
read: false,
|
||||
createdAt: '2024-06-15T10:00:00',
|
||||
documentTitle: 'Geburtsurkunde Opa Karl'
|
||||
};
|
||||
|
||||
it('should return parsed object for a valid payload', () => {
|
||||
const result = parseNotificationEvent(JSON.stringify(valid));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe(valid.id);
|
||||
expect(result?.actorName).toBe('Anna Müller');
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON', () => {
|
||||
expect(parseNotificationEvent('not-json')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when id is missing', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, ...noId } = valid;
|
||||
expect(parseNotificationEvent(JSON.stringify(noId))).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when documentId is missing', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { documentId, ...noDocId } = valid;
|
||||
expect(parseNotificationEvent(JSON.stringify(noDocId))).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when actorName is missing', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { actorName, ...noActor } = valid;
|
||||
expect(parseNotificationEvent(JSON.stringify(noActor))).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unknown notification type', () => {
|
||||
expect(parseNotificationEvent(JSON.stringify({ ...valid, type: 'UNKNOWN' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept REPLY as a valid type', () => {
|
||||
const result = parseNotificationEvent(JSON.stringify({ ...valid, type: 'REPLY' }));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('REPLY');
|
||||
});
|
||||
});
|
||||
200
frontend/src/lib/notification/notifications.svelte.spec.ts
Normal file
200
frontend/src/lib/notification/notifications.svelte.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { NotificationItem } from '$lib/notification/notifications';
|
||||
|
||||
let lastEventSource: MockEventSource | null = null;
|
||||
let eventSourceCount = 0;
|
||||
|
||||
class MockEventSource {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSED = 2;
|
||||
|
||||
readyState = MockEventSource.CONNECTING;
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void | Promise<void>) | null = null;
|
||||
close = vi.fn();
|
||||
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
|
||||
|
||||
constructor() {
|
||||
eventSourceCount += 1;
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
lastEventSource = this;
|
||||
}
|
||||
|
||||
addEventListener(type: string, fn: (e: MessageEvent) => void) {
|
||||
if (!this.listeners[type]) this.listeners[type] = [];
|
||||
this.listeners[type].push(fn);
|
||||
}
|
||||
|
||||
simulate(type: string, data: string) {
|
||||
const event = new MessageEvent(type, { data });
|
||||
for (const fn of this.listeners[type] ?? []) {
|
||||
fn(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('EventSource', MockEventSource);
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { notificationStore, __resetForTest, __setNavigateForTest } =
|
||||
await import('./notifications.svelte');
|
||||
|
||||
let navigateSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
||||
lastEventSource = null;
|
||||
eventSourceCount = 0;
|
||||
navigateSpy = vi.fn();
|
||||
__setNavigateForTest(navigateSpy);
|
||||
__resetForTest();
|
||||
});
|
||||
|
||||
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: 'REPLY',
|
||||
actorName: 'Hans',
|
||||
documentId: 'doc-1',
|
||||
documentTitle: null,
|
||||
referenceId: 'ref-1',
|
||||
annotationId: null,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('notificationStore (singleton)', () => {
|
||||
it('opens a single EventSource across multiple init() calls', () => {
|
||||
notificationStore.init();
|
||||
notificationStore.init();
|
||||
notificationStore.init();
|
||||
|
||||
expect(eventSourceCount).toBe(1);
|
||||
});
|
||||
|
||||
it('closes the EventSource only after every init() is matched with destroy()', () => {
|
||||
notificationStore.init();
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
|
||||
notificationStore.destroy();
|
||||
expect(es.close).not.toHaveBeenCalled();
|
||||
|
||||
notificationStore.destroy();
|
||||
expect(es.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reopens a fresh EventSource after full teardown', () => {
|
||||
notificationStore.init();
|
||||
notificationStore.destroy();
|
||||
notificationStore.init();
|
||||
|
||||
expect(eventSourceCount).toBe(2);
|
||||
});
|
||||
|
||||
it('SSE notification event prepends notification and increments unreadCount', () => {
|
||||
notificationStore.init();
|
||||
|
||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||
|
||||
expect(notificationStore.notifications[0].id).toBe('sse-1');
|
||||
expect(notificationStore.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('markAllRead resets unreadCount', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
await notificationStore.markAllRead();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
||||
expect(notificationStore.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notificationStore onerror handler', () => {
|
||||
it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CLOSED;
|
||||
|
||||
await es.onerror?.();
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('does not redirect when readyState is CLOSED and session is still valid', async () => {
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CLOSED;
|
||||
|
||||
await es.onerror?.();
|
||||
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
|
||||
expect(es.close).not.toHaveBeenCalled();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
|
||||
expect(es.close).toHaveBeenCalledTimes(1);
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('closes but does not redirect after threshold when session is still valid', async () => {
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
|
||||
expect(es.close).toHaveBeenCalledTimes(1);
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets error count after a successful reconnect (onopen)', async () => {
|
||||
notificationStore.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
// Two errors — not yet at threshold
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
|
||||
// Successful reconnect resets counter
|
||||
es.onopen?.();
|
||||
|
||||
// Two more errors — should still be below threshold
|
||||
await es.onerror?.();
|
||||
await es.onerror?.();
|
||||
|
||||
expect(es.close).not.toHaveBeenCalled();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
130
frontend/src/lib/notification/notifications.svelte.ts
Normal file
130
frontend/src/lib/notification/notifications.svelte.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
|
||||
|
||||
export type { NotificationItem };
|
||||
|
||||
let notifications = $state<NotificationItem[]>([]);
|
||||
let unreadCount = $state(0);
|
||||
let eventSource: EventSource | null = null;
|
||||
let refCount = 0;
|
||||
let errorCount = 0;
|
||||
let navigate: (url: string) => void = (url) => {
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/notifications?size=10');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notifications = data.content ?? [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
unreadCount = data.count;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch unread count', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(notification: NotificationItem): Promise<void> {
|
||||
if (!notification.read) {
|
||||
try {
|
||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(): Promise<void> {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||
for (const n of notifications) {
|
||||
n.read = true;
|
||||
}
|
||||
unreadCount = 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to mark all notifications as read', e);
|
||||
}
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
refCount += 1;
|
||||
if (refCount > 1) return;
|
||||
|
||||
fetchUnreadCount();
|
||||
eventSource = new EventSource('/api/notifications/stream');
|
||||
eventSource.addEventListener('notification', (e) => {
|
||||
const notification = parseNotificationEvent((e as MessageEvent).data);
|
||||
if (!notification) return;
|
||||
notifications = [notification, ...notifications];
|
||||
if (!notification.read) unreadCount += 1;
|
||||
});
|
||||
eventSource.onopen = () => {
|
||||
fetchUnreadCount();
|
||||
errorCount = 0;
|
||||
};
|
||||
eventSource.onerror = async () => {
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.status === 401) navigate('/login');
|
||||
return;
|
||||
}
|
||||
errorCount += 1;
|
||||
if (errorCount >= 3) {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
errorCount = 0;
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.status === 401) navigate('/login');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (refCount === 0) return;
|
||||
refCount -= 1;
|
||||
if (refCount === 0) {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetForTest(): void {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
refCount = 0;
|
||||
errorCount = 0;
|
||||
notifications = [];
|
||||
unreadCount = 0;
|
||||
}
|
||||
|
||||
export function __setNavigateForTest(fn: (url: string) => void): void {
|
||||
navigate = fn;
|
||||
}
|
||||
|
||||
export const notificationStore = {
|
||||
get notifications() {
|
||||
return notifications;
|
||||
},
|
||||
get unreadCount() {
|
||||
return unreadCount;
|
||||
},
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
markRead,
|
||||
markAllRead,
|
||||
init,
|
||||
destroy
|
||||
};
|
||||
31
frontend/src/lib/notification/notifications.ts
Normal file
31
frontend/src/lib/notification/notifications.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type NotificationItem = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId: string;
|
||||
referenceId: string;
|
||||
annotationId: string | null;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName: string;
|
||||
documentTitle: string | null;
|
||||
};
|
||||
|
||||
export { relativeTime } from '$lib/utils/time';
|
||||
|
||||
export function parseNotificationEvent(raw: string): NotificationItem | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed.id !== 'string' ||
|
||||
typeof parsed.documentId !== 'string' ||
|
||||
typeof parsed.actorName !== 'string' ||
|
||||
!['REPLY', 'MENTION'].includes(parsed.type)
|
||||
) {
|
||||
console.warn('Unexpected SSE payload shape:', parsed);
|
||||
return null;
|
||||
}
|
||||
return parsed as NotificationItem;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user