refactor(notification): provide notification store via context + fixture
Converts the module-singleton notificationStore into a context-provided store so its specs can drive it without mocking the module. notifications.svelte now exports createNotificationStore() (the former singleton body), plus provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY mirroring the confirm service. Root +layout provides it; NotificationBell and the Chronik page read it via getNotificationStore(). Tests: - notifications.svelte.spec drives a fresh createNotificationStore() per test (replacing __resetForTest/__setNavigateForTest with setNavigate()). - notification.test-fixture.svelte wraps the bell, provides the store, and exposes setNotifications(items) via onReady (option b). - NotificationBell.svelte.spec asserts the announced unread count across the empty / single / many / error a11y states (AC#5), stubbing EventSource+fetch. - aktivitaeten page spec injects a real store via render context. Per the recorded Phase-2b decision (full context refactor). Part of #560. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,13 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
import { getNotificationStore } from '$lib/notification/notifications.svelte';
|
||||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let bellButtonEl: HTMLButtonElement | null = null;
|
let bellButtonEl: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
const stream = notificationStore;
|
const stream = getNotificationStore();
|
||||||
|
|
||||||
async function toggleDropdown() {
|
async function toggleDropdown() {
|
||||||
open = !open;
|
open = !open;
|
||||||
|
|||||||
@@ -1,36 +1,43 @@
|
|||||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
import NotificationBell from './NotificationBell.svelte';
|
|
||||||
import * as formsMock from '$mocks/$app/forms';
|
import * as formsMock from '$mocks/$app/forms';
|
||||||
|
import NotificationFixture from './notification.test-fixture.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
||||||
vi.mock('$app/forms', () => ({ ...formsMock }));
|
vi.mock('$app/forms', () => ({ ...formsMock }));
|
||||||
|
|
||||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
// NotificationBell.onMount calls store.init(), which opens an EventSource and
|
||||||
|
// fetches the unread count. Stub both so no real network or 401 → /login
|
||||||
|
// navigation fires; the real store + provideNotificationStore() run otherwise.
|
||||||
|
class NoopEventSource {
|
||||||
|
static CLOSED = 2;
|
||||||
|
readyState = 0;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
addEventListener() {}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
beforeEach(() => {
|
||||||
notificationStore: {
|
vi.stubGlobal('EventSource', NoopEventSource);
|
||||||
get notifications() {
|
vi.stubGlobal(
|
||||||
return mockNotificationList.value;
|
'fetch',
|
||||||
},
|
vi
|
||||||
get unreadCount() {
|
.fn()
|
||||||
return mockNotificationList.value.length;
|
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
|
||||||
},
|
);
|
||||||
optimisticMarkRead: vi.fn(),
|
});
|
||||||
optimisticMarkAllRead: vi.fn(),
|
|
||||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
|
||||||
init: vi.fn(),
|
|
||||||
destroy: vi.fn()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockNotificationList.value = [];
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||||
id: 'n1',
|
id: 'n1',
|
||||||
type: 'REPLY',
|
type: 'REPLY',
|
||||||
@@ -44,30 +51,85 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('NotificationBell — cursor and tooltip', () => {
|
type Api = { setNotifications: (items: NotificationItem[]) => void };
|
||||||
|
|
||||||
|
function renderBell(): Api {
|
||||||
|
let api: Api = { setNotifications: () => {} };
|
||||||
|
render(NotificationFixture, { onReady: (a: Api) => (api = a) });
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bellButton(): HTMLButtonElement {
|
||||||
|
return document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unreadBadge(): HTMLElement {
|
||||||
|
return bellButton().querySelector<HTMLElement>('[aria-live="polite"]')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell — rendering', () => {
|
||||||
it('bell button has cursor-pointer class', async () => {
|
it('bell button has cursor-pointer class', async () => {
|
||||||
render(NotificationBell);
|
renderBell();
|
||||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
await tick();
|
||||||
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
expect(bellButton().classList.contains('cursor-pointer')).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
|
||||||
mockNotificationList.value = [];
|
// AC#5: the bell's announced unread count must hold across the four a11y states.
|
||||||
render(NotificationBell);
|
// The count is announced via the aria-live badge text and the button title /
|
||||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
// aria-label; both must stay consistent as the store's notifications change.
|
||||||
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
describe('NotificationBell — announced unread count across a11y states', () => {
|
||||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
it('empty: announces no unread count and hides the live badge', async () => {
|
||||||
});
|
const { setNotifications } = renderBell();
|
||||||
|
await tick();
|
||||||
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
setNotifications([]);
|
||||||
mockNotificationList.value = [
|
await tick();
|
||||||
makeNotification({ id: 'n1' }),
|
|
||||||
makeNotification({ id: 'n2' }),
|
const btn = bellButton();
|
||||||
makeNotification({ id: 'n3' })
|
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
|
||||||
];
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
render(NotificationBell);
|
expect(unreadBadge().classList.contains('hidden')).toBe(true);
|
||||||
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'));
|
it('single: announces one unread notification', async () => {
|
||||||
|
const { setNotifications } = renderBell();
|
||||||
|
await tick();
|
||||||
|
setNotifications([makeNotification({ id: 'n1', read: false })]);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const btn = bellButton();
|
||||||
|
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 }));
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
expect(unreadBadge().classList.contains('hidden')).toBe(false);
|
||||||
|
expect(unreadBadge().textContent?.trim()).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('many: announces the exact unread count', async () => {
|
||||||
|
const { setNotifications } = renderBell();
|
||||||
|
await tick();
|
||||||
|
setNotifications([
|
||||||
|
makeNotification({ id: 'n1', read: false }),
|
||||||
|
makeNotification({ id: 'n2', read: false }),
|
||||||
|
makeNotification({ id: 'n3', read: false })
|
||||||
|
]);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const btn = bellButton();
|
||||||
|
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 3 }));
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
expect(unreadBadge().textContent?.trim()).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error: degrades to a zero announced count when the unread-count load fails', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
||||||
|
renderBell();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// A failed load leaves the count at 0; the bell still announces a valid,
|
||||||
|
// non-urgent state rather than a broken count.
|
||||||
|
const btn = bellButton();
|
||||||
|
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
expect(unreadBadge().classList.contains('hidden')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { provideNotificationStore, type NotificationItem } from './notifications.svelte';
|
||||||
|
import NotificationBell from './NotificationBell.svelte';
|
||||||
|
|
||||||
|
type Api = { setNotifications: (items: NotificationItem[]) => void };
|
||||||
|
|
||||||
|
let { onReady }: { onReady: (api: Api) => void } = $props();
|
||||||
|
|
||||||
|
const store = provideNotificationStore();
|
||||||
|
onReady({ setNotifications: store.setNotifications });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NotificationBell />
|
||||||
@@ -39,19 +39,20 @@ vi.stubGlobal('EventSource', MockEventSource);
|
|||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
const { notificationStore, __resetForTest, __setNavigateForTest } =
|
const { createNotificationStore } = await import('./notifications.svelte');
|
||||||
await import('./notifications.svelte');
|
|
||||||
|
|
||||||
let navigateSpy: ReturnType<typeof vi.fn>;
|
let store: ReturnType<typeof createNotificationStore>;
|
||||||
|
let navigateSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
||||||
lastEventSource = null;
|
lastEventSource = null;
|
||||||
eventSourceCount = 0;
|
eventSourceCount = 0;
|
||||||
navigateSpy = vi.fn();
|
navigateSpy = vi.fn<(url: string) => void>();
|
||||||
__setNavigateForTest(navigateSpy);
|
// A fresh instance per test replaces the old __resetForTest() singleton reset.
|
||||||
__resetForTest();
|
store = createNotificationStore();
|
||||||
|
store.setNavigate(navigateSpy);
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||||
@@ -69,70 +70,70 @@ function makeNotification(overrides: Partial<NotificationItem> = {}): Notificati
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('notificationStore (singleton)', () => {
|
describe('notification store', () => {
|
||||||
it('opens a single EventSource across multiple init() calls', () => {
|
it('opens a single EventSource across multiple init() calls', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
notificationStore.init();
|
store.init();
|
||||||
notificationStore.init();
|
store.init();
|
||||||
|
|
||||||
expect(eventSourceCount).toBe(1);
|
expect(eventSourceCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the EventSource only after every init() is matched with destroy()', () => {
|
it('closes the EventSource only after every init() is matched with destroy()', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
|
|
||||||
notificationStore.destroy();
|
store.destroy();
|
||||||
expect(es.close).not.toHaveBeenCalled();
|
expect(es.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
notificationStore.destroy();
|
store.destroy();
|
||||||
expect(es.close).toHaveBeenCalledTimes(1);
|
expect(es.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reopens a fresh EventSource after full teardown', () => {
|
it('reopens a fresh EventSource after full teardown', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
notificationStore.destroy();
|
store.destroy();
|
||||||
notificationStore.init();
|
store.init();
|
||||||
|
|
||||||
expect(eventSourceCount).toBe(2);
|
expect(eventSourceCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SSE notification event prepends notification and increments unreadCount', () => {
|
it('SSE notification event prepends notification and increments unreadCount', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
|
|
||||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
expect(notificationStore.notifications[0].id).toBe('sse-1');
|
expect(store.notifications[0].id).toBe('sse-1');
|
||||||
expect(notificationStore.unreadCount).toBe(1);
|
expect(store.unreadCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
||||||
|
|
||||||
notificationStore.optimisticMarkRead('sse-1');
|
store.optimisticMarkRead('sse-1');
|
||||||
|
|
||||||
expect(notificationStore.notifications[0].read).toBe(true);
|
expect(store.notifications[0].read).toBe(true);
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
expect(store.unreadCount).toBe(0);
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
|
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const notification = makeNotification({ id: 'sse-1', read: true });
|
const notification = makeNotification({ id: 'sse-1', read: true });
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
notificationStore.optimisticMarkRead('sse-1');
|
store.optimisticMarkRead('sse-1');
|
||||||
|
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
expect(store.unreadCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
|
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
lastEventSource!.simulate(
|
lastEventSource!.simulate(
|
||||||
'notification',
|
'notification',
|
||||||
JSON.stringify(makeNotification({ id: 'n1', read: false }))
|
JSON.stringify(makeNotification({ id: 'n1', read: false }))
|
||||||
@@ -143,18 +144,18 @@ describe('notificationStore (singleton)', () => {
|
|||||||
);
|
);
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
|
|
||||||
notificationStore.optimisticMarkAllRead();
|
store.optimisticMarkAllRead();
|
||||||
|
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
expect(store.unreadCount).toBe(0);
|
||||||
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
|
expect(store.notifications.every((n) => n.read)).toBe(true);
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('notificationStore onerror handler', () => {
|
describe('notification store onerror handler', () => {
|
||||||
it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
|
it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
|
||||||
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
es.readyState = MockEventSource.CLOSED;
|
es.readyState = MockEventSource.CLOSED;
|
||||||
|
|
||||||
@@ -164,7 +165,7 @@ describe('notificationStore onerror handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not redirect when readyState is CLOSED and session is still valid', async () => {
|
it('does not redirect when readyState is CLOSED and session is still valid', async () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
es.readyState = MockEventSource.CLOSED;
|
es.readyState = MockEventSource.CLOSED;
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ describe('notificationStore onerror handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
|
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
es.readyState = MockEventSource.CONNECTING;
|
es.readyState = MockEventSource.CONNECTING;
|
||||||
|
|
||||||
@@ -187,7 +188,7 @@ describe('notificationStore onerror handler', () => {
|
|||||||
|
|
||||||
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
|
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
|
||||||
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
es.readyState = MockEventSource.CONNECTING;
|
es.readyState = MockEventSource.CONNECTING;
|
||||||
|
|
||||||
@@ -200,7 +201,7 @@ describe('notificationStore onerror handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('closes but does not redirect after threshold when session is still valid', async () => {
|
it('closes but does not redirect after threshold when session is still valid', async () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
es.readyState = MockEventSource.CONNECTING;
|
es.readyState = MockEventSource.CONNECTING;
|
||||||
|
|
||||||
@@ -213,7 +214,7 @@ describe('notificationStore onerror handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('resets error count after a successful reconnect (onopen)', async () => {
|
it('resets error count after a successful reconnect (onopen)', async () => {
|
||||||
notificationStore.init();
|
store.init();
|
||||||
const es = lastEventSource!;
|
const es = lastEventSource!;
|
||||||
es.readyState = MockEventSource.CONNECTING;
|
es.readyState = MockEventSource.CONNECTING;
|
||||||
|
|
||||||
|
|||||||
@@ -1,121 +1,161 @@
|
|||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
|
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
|
||||||
|
|
||||||
export type { NotificationItem };
|
export type { NotificationItem };
|
||||||
|
|
||||||
let notifications = $state<NotificationItem[]>([]);
|
export const NOTIFICATION_KEY = Symbol('notification');
|
||||||
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> {
|
export interface NotificationStore {
|
||||||
try {
|
readonly notifications: NotificationItem[];
|
||||||
const res = await fetch('/api/notifications?size=10');
|
readonly unreadCount: number;
|
||||||
if (res.ok) {
|
fetchNotifications(): Promise<void>;
|
||||||
const data = await res.json();
|
fetchUnreadCount(): Promise<void>;
|
||||||
notifications = data.content ?? [];
|
optimisticMarkRead(id: string): void;
|
||||||
}
|
optimisticMarkAllRead(): void;
|
||||||
} catch (e) {
|
init(): void;
|
||||||
console.error('Failed to fetch notifications', e);
|
destroy(): void;
|
||||||
}
|
/** Test-only: seed the notification list and derive the unread count. */
|
||||||
|
setNotifications(items: NotificationItem[]): void;
|
||||||
|
/** Test-only: override the 401 → redirect side-effect. */
|
||||||
|
setNavigate(fn: (url: string) => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUnreadCount(): Promise<void> {
|
export function createNotificationStore(): NotificationStore {
|
||||||
try {
|
let notifications = $state<NotificationItem[]>([]);
|
||||||
const res = await fetch('/api/notifications/unread-count');
|
let unreadCount = $state(0);
|
||||||
if (res.ok) {
|
let eventSource: EventSource | null = null;
|
||||||
const data = await res.json();
|
let refCount = 0;
|
||||||
unreadCount = data.count;
|
let errorCount = 0;
|
||||||
}
|
let navigate: (url: string) => void = (url) => {
|
||||||
} catch (e) {
|
window.location.href = url;
|
||||||
console.error('Failed to fetch unread count', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimisticMarkRead(id: string): void {
|
|
||||||
const notification = notifications.find((n) => n.id === id);
|
|
||||||
if (notification && !notification.read) {
|
|
||||||
notification.read = true;
|
|
||||||
unreadCount = Math.max(0, unreadCount - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimisticMarkAllRead(): void {
|
|
||||||
for (const n of notifications) {
|
|
||||||
n.read = true;
|
|
||||||
}
|
|
||||||
unreadCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async function fetchNotifications(): Promise<void> {
|
||||||
const res = await fetch('/api/notifications/unread-count');
|
try {
|
||||||
if (res.status === 401) navigate('/login');
|
const res = await fetch('/api/notifications?size=10');
|
||||||
return;
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
notifications = data.content ?? [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch notifications', e);
|
||||||
}
|
}
|
||||||
errorCount += 1;
|
}
|
||||||
if (errorCount >= 3) {
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticMarkRead(id: string): void {
|
||||||
|
const notification = notifications.find((n) => n.id === id);
|
||||||
|
if (notification && !notification.read) {
|
||||||
|
notification.read = true;
|
||||||
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticMarkAllRead(): void {
|
||||||
|
for (const n of notifications) {
|
||||||
|
n.read = true;
|
||||||
|
}
|
||||||
|
unreadCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
errorCount = 0;
|
}
|
||||||
const res = await fetch('/api/notifications/unread-count');
|
}
|
||||||
if (res.status === 401) navigate('/login');
|
|
||||||
|
return {
|
||||||
|
get notifications() {
|
||||||
|
return notifications;
|
||||||
|
},
|
||||||
|
get unreadCount() {
|
||||||
|
return unreadCount;
|
||||||
|
},
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
optimisticMarkRead,
|
||||||
|
optimisticMarkAllRead,
|
||||||
|
init,
|
||||||
|
destroy,
|
||||||
|
setNotifications(items: NotificationItem[]): void {
|
||||||
|
notifications = items;
|
||||||
|
unreadCount = items.filter((n) => !n.read).length;
|
||||||
|
},
|
||||||
|
setNavigate(fn: (url: string) => void): void {
|
||||||
|
navigate = fn;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy(): void {
|
/**
|
||||||
if (refCount === 0) return;
|
* Create a notification store and put it on the context. Call once high in the
|
||||||
refCount -= 1;
|
* tree (root +layout.svelte). Descendants read it via getNotificationStore().
|
||||||
if (refCount === 0) {
|
*/
|
||||||
eventSource?.close();
|
export function provideNotificationStore(): NotificationStore {
|
||||||
eventSource = null;
|
const store = createNotificationStore();
|
||||||
|
setContext(NOTIFICATION_KEY, store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationStore(): NotificationStore {
|
||||||
|
let store: NotificationStore | undefined;
|
||||||
|
try {
|
||||||
|
store = getContext<NotificationStore>(NOTIFICATION_KEY);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
if (!store)
|
||||||
|
throw new Error(
|
||||||
|
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
|
||||||
|
);
|
||||||
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
optimisticMarkRead,
|
|
||||||
optimisticMarkAllRead,
|
|
||||||
init,
|
|
||||||
destroy
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
|
|||||||
import UserMenu from './UserMenu.svelte';
|
import UserMenu from './UserMenu.svelte';
|
||||||
import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte';
|
||||||
import { provideConfirmService } from '$lib/shared/services/confirm.svelte';
|
import { provideConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||||
|
import { provideNotificationStore } from '$lib/notification/notifications.svelte';
|
||||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
@@ -18,6 +19,11 @@ let { children, data } = $props();
|
|||||||
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
|
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
|
||||||
provideConfirmService();
|
provideConfirmService();
|
||||||
|
|
||||||
|
// Provide the notification store to the tree. NotificationBell (header) and the
|
||||||
|
// Chronik page read it via getNotificationStore(); the bell drives the SSE
|
||||||
|
// lifecycle through init()/destroy() on mount.
|
||||||
|
provideNotificationStore();
|
||||||
|
|
||||||
// Auto-clear the bulk-selection store when the user leaves the routes that
|
// Auto-clear the bulk-selection store when the user leaves the routes that
|
||||||
// surface the BulkSelectionBar. Without this the selection silently follows
|
// surface the BulkSelectionBar. Without this the selection silently follows
|
||||||
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
|
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { onMount, onDestroy } from 'svelte';
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page, navigating } from '$app/state';
|
import { page, navigating } from '$app/state';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { notificationStore, type NotificationItem } from '$lib/notification/notifications.svelte';
|
import {
|
||||||
|
getNotificationStore,
|
||||||
|
type NotificationItem
|
||||||
|
} from '$lib/notification/notifications.svelte';
|
||||||
import ChronikFuerDichBox from '$lib/activity/ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from '$lib/activity/ChronikFuerDichBox.svelte';
|
||||||
import ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte';
|
import ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte';
|
||||||
import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte';
|
import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte';
|
||||||
@@ -26,9 +29,11 @@ interface Props {
|
|||||||
|
|
||||||
const { data }: Props = $props();
|
const { data }: Props = $props();
|
||||||
|
|
||||||
// Prefer the live SSE singleton for unread items so newly arriving mentions
|
const notificationStore = getNotificationStore();
|
||||||
|
|
||||||
|
// Prefer the live SSE store for unread items so newly arriving mentions
|
||||||
// prepend without a reload. On first mount, seed from the server-loaded unread
|
// prepend without a reload. On first mount, seed from the server-loaded unread
|
||||||
// set if the singleton hasn't populated yet.
|
// set if the store hasn't populated yet.
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
notificationStore.init();
|
notificationStore.init();
|
||||||
});
|
});
|
||||||
@@ -59,7 +64,7 @@ const seedUnread = $derived<NotificationItem[]>(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the singleton has any data (including zero after mark-all), trust it;
|
// If the store has any data (including zero after mark-all), trust it;
|
||||||
// otherwise fall back to the SSR-seeded unread set.
|
// otherwise fall back to the SSR-seeded unread set.
|
||||||
const unread = $derived<NotificationItem[]>(
|
const unread = $derived<NotificationItem[]>(
|
||||||
notificationStore.notifications.length > 0 ? liveUnread : seedUnread
|
notificationStore.notifications.length > 0 ? liveUnread : seedUnread
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
import { createNotificationStore, NOTIFICATION_KEY } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
const mockNavigating = { type: null };
|
const mockNavigating = { type: null };
|
||||||
const mockPage = { url: new URL('http://localhost/aktivitaeten') };
|
const mockPage = { url: new URL('http://localhost/aktivitaeten') };
|
||||||
@@ -28,19 +29,35 @@ vi.mock('$app/navigation', () => ({
|
|||||||
onNavigate: () => () => {}
|
onNavigate: () => () => {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
// The Chronik page's onMount calls store.init(), opening an EventSource and
|
||||||
notificationStore: {
|
// fetching the unread count. Stub both so no real network / 401 → /login fires.
|
||||||
notifications: [],
|
class NoopEventSource {
|
||||||
init: vi.fn(),
|
static CLOSED = 2;
|
||||||
destroy: vi.fn(),
|
readyState = 0;
|
||||||
markRead: vi.fn(),
|
onopen: (() => void) | null = null;
|
||||||
markAllRead: vi.fn()
|
onerror: (() => void) | null = null;
|
||||||
}
|
addEventListener() {}
|
||||||
}));
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('EventSource', NoopEventSource);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const { default: AktivitaetenPage } = await import('./+page.svelte');
|
const { default: AktivitaetenPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationContext = () => new Map([[NOTIFICATION_KEY, createNotificationStore()]]);
|
||||||
|
|
||||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||||
filter: 'alle' as const,
|
filter: 'alle' as const,
|
||||||
@@ -52,13 +69,16 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
|
|
||||||
describe('aktivitaeten page', () => {
|
describe('aktivitaeten page', () => {
|
||||||
it('renders the page heading', async () => {
|
it('renders the page heading', async () => {
|
||||||
render(AktivitaetenPage, { props: { data: baseData() } });
|
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
|
||||||
|
|
||||||
await expect.element(page.getByRole('heading', { name: /aktivitäten/i })).toBeVisible();
|
await expect.element(page.getByRole('heading', { name: /aktivitäten/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the error card when loadError is "activity"', async () => {
|
it('renders the error card when loadError is "activity"', async () => {
|
||||||
render(AktivitaetenPage, { props: { data: baseData({ loadError: 'activity' }) } });
|
render(AktivitaetenPage, {
|
||||||
|
context: notificationContext(),
|
||||||
|
props: { data: baseData({ loadError: 'activity' }) }
|
||||||
|
});
|
||||||
|
|
||||||
// ChronikErrorCard renders some retry mechanism
|
// ChronikErrorCard renders some retry mechanism
|
||||||
const main = document.querySelector('main');
|
const main = document.querySelector('main');
|
||||||
@@ -69,7 +89,7 @@ describe('aktivitaeten page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the FuerDichBox and FilterPills when loadError is null', async () => {
|
it('renders the FuerDichBox and FilterPills when loadError is null', async () => {
|
||||||
render(AktivitaetenPage, { props: { data: baseData() } });
|
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
|
||||||
|
|
||||||
// FuerDichBox shows the inbox-zero state when no unread
|
// FuerDichBox shows the inbox-zero state when no unread
|
||||||
const fuerDich = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
const fuerDich = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||||
@@ -81,7 +101,7 @@ describe('aktivitaeten page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the first-run empty state when activityFeed is empty', async () => {
|
it('renders the first-run empty state when activityFeed is empty', async () => {
|
||||||
render(AktivitaetenPage, { props: { data: baseData() } });
|
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
|
||||||
|
|
||||||
const empty = document.querySelector('[data-testid="chronik-empty-state"]');
|
const empty = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||||
expect(empty?.getAttribute('data-variant')).toBe('first-run');
|
expect(empty?.getAttribute('data-variant')).toBe('first-run');
|
||||||
@@ -89,6 +109,7 @@ describe('aktivitaeten page', () => {
|
|||||||
|
|
||||||
it('renders the filter-empty empty state when feed has items but filter rules out all', async () => {
|
it('renders the filter-empty empty state when feed has items but filter rules out all', async () => {
|
||||||
render(AktivitaetenPage, {
|
render(AktivitaetenPage, {
|
||||||
|
context: notificationContext(),
|
||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
filter: 'fuer-dich' as const,
|
filter: 'fuer-dich' as const,
|
||||||
@@ -114,6 +135,7 @@ describe('aktivitaeten page', () => {
|
|||||||
|
|
||||||
it('renders the timeline when displayFeed is non-empty', async () => {
|
it('renders the timeline when displayFeed is non-empty', async () => {
|
||||||
render(AktivitaetenPage, {
|
render(AktivitaetenPage, {
|
||||||
|
context: notificationContext(),
|
||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
filter: 'alle' as const,
|
filter: 'alle' as const,
|
||||||
@@ -142,6 +164,7 @@ describe('aktivitaeten page', () => {
|
|||||||
|
|
||||||
it('renders without crashing when filter is set to a non-default value', async () => {
|
it('renders without crashing when filter is set to a non-default value', async () => {
|
||||||
render(AktivitaetenPage, {
|
render(AktivitaetenPage, {
|
||||||
|
context: notificationContext(),
|
||||||
props: { data: baseData({ filter: 'transkription' as const }) }
|
props: { data: baseData({ filter: 'transkription' as const }) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user