diff --git a/frontend/src/lib/notification/NotificationBell.svelte b/frontend/src/lib/notification/NotificationBell.svelte index 3a04bb35..38a2513f 100644 --- a/frontend/src/lib/notification/NotificationBell.svelte +++ b/frontend/src/lib/notification/NotificationBell.svelte @@ -2,13 +2,13 @@ import { onMount, onDestroy } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; 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'; let open = $state(false); let bellButtonEl: HTMLButtonElement | null = null; -const stream = notificationStore; +const stream = getNotificationStore(); async function toggleDropdown() { open = !open; diff --git a/frontend/src/lib/notification/NotificationBell.svelte.spec.ts b/frontend/src/lib/notification/NotificationBell.svelte.spec.ts index bf5f5f62..256ee342 100644 --- a/frontend/src/lib/notification/NotificationBell.svelte.spec.ts +++ b/frontend/src/lib/notification/NotificationBell.svelte.spec.ts @@ -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 { m } from '$lib/paraglide/messages.js'; import type { NotificationItem } from '$lib/notification/notifications'; -import NotificationBell from './NotificationBell.svelte'; 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/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', () => ({ - notificationStore: { - get notifications() { - return mockNotificationList.value; - }, - get unreadCount() { - return mockNotificationList.value.length; - }, - optimisticMarkRead: vi.fn(), - optimisticMarkAllRead: vi.fn(), - fetchNotifications: vi.fn().mockResolvedValue(undefined), - init: vi.fn(), - destroy: vi.fn() - } -})); +beforeEach(() => { + vi.stubGlobal('EventSource', NoopEventSource); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 })) + ); +}); afterEach(() => { cleanup(); vi.clearAllMocks(); - mockNotificationList.value = []; + vi.unstubAllGlobals(); }); +const tick = () => new Promise((r) => setTimeout(r, 0)); + const makeNotification = (overrides: Partial = {}): NotificationItem => ({ id: 'n1', type: 'REPLY', @@ -44,30 +51,85 @@ const makeNotification = (overrides: Partial = {}): Notificati ...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('button[aria-haspopup="true"]')!; +} + +function unreadBadge(): HTMLElement { + return bellButton().querySelector('[aria-live="polite"]')!; +} + +describe('NotificationBell — rendering', () => { it('bell button has cursor-pointer class', async () => { - render(NotificationBell); - const btn = document.querySelector('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('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('button[aria-haspopup="true"]')!; - expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen'); - expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); + renderBell(); + await tick(); + expect(bellButton().classList.contains('cursor-pointer')).toBe(true); + }); +}); + +// AC#5: the bell's announced unread count must hold across the four a11y states. +// The count is announced via the aria-live badge text and the button title / +// aria-label; both must stay consistent as the store's notifications change. +describe('NotificationBell — announced unread count across a11y states', () => { + it('empty: announces no unread count and hides the live badge', async () => { + const { setNotifications } = renderBell(); + await tick(); + setNotifications([]); + await tick(); + + 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); + }); + + 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); }); }); diff --git a/frontend/src/lib/notification/notification.test-fixture.svelte b/frontend/src/lib/notification/notification.test-fixture.svelte new file mode 100644 index 00000000..9ebfc484 --- /dev/null +++ b/frontend/src/lib/notification/notification.test-fixture.svelte @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/lib/notification/notifications.svelte.spec.ts b/frontend/src/lib/notification/notifications.svelte.spec.ts index 15345286..72ac98e3 100644 --- a/frontend/src/lib/notification/notifications.svelte.spec.ts +++ b/frontend/src/lib/notification/notifications.svelte.spec.ts @@ -39,19 +39,20 @@ vi.stubGlobal('EventSource', MockEventSource); const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); -const { notificationStore, __resetForTest, __setNavigateForTest } = - await import('./notifications.svelte'); +const { createNotificationStore } = await import('./notifications.svelte'); -let navigateSpy: ReturnType; +let store: ReturnType; +let navigateSpy: ReturnType void>>; beforeEach(() => { mockFetch.mockReset(); mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 })); lastEventSource = null; eventSourceCount = 0; - navigateSpy = vi.fn(); - __setNavigateForTest(navigateSpy); - __resetForTest(); + navigateSpy = vi.fn<(url: string) => void>(); + // A fresh instance per test replaces the old __resetForTest() singleton reset. + store = createNotificationStore(); + store.setNavigate(navigateSpy); }); function makeNotification(overrides: Partial = {}): NotificationItem { @@ -69,70 +70,70 @@ function makeNotification(overrides: Partial = {}): Notificati }; } -describe('notificationStore (singleton)', () => { +describe('notification store', () => { it('opens a single EventSource across multiple init() calls', () => { - notificationStore.init(); - notificationStore.init(); - notificationStore.init(); + store.init(); + store.init(); + store.init(); expect(eventSourceCount).toBe(1); }); it('closes the EventSource only after every init() is matched with destroy()', () => { - notificationStore.init(); - notificationStore.init(); + store.init(); + store.init(); const es = lastEventSource!; - notificationStore.destroy(); + store.destroy(); expect(es.close).not.toHaveBeenCalled(); - notificationStore.destroy(); + store.destroy(); expect(es.close).toHaveBeenCalledTimes(1); }); it('reopens a fresh EventSource after full teardown', () => { - notificationStore.init(); - notificationStore.destroy(); - notificationStore.init(); + store.init(); + store.destroy(); + store.init(); expect(eventSourceCount).toBe(2); }); it('SSE notification event prepends notification and increments unreadCount', () => { - notificationStore.init(); + store.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); + expect(store.notifications[0].id).toBe('sse-1'); + expect(store.unreadCount).toBe(1); }); it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { - notificationStore.init(); + store.init(); const notification = makeNotification({ id: 'sse-1', read: false }); lastEventSource!.simulate('notification', JSON.stringify(notification)); mockFetch.mockReset(); // clear the fetchUnreadCount call from init - notificationStore.optimisticMarkRead('sse-1'); + store.optimisticMarkRead('sse-1'); - expect(notificationStore.notifications[0].read).toBe(true); - expect(notificationStore.unreadCount).toBe(0); + expect(store.notifications[0].read).toBe(true); + expect(store.unreadCount).toBe(0); expect(mockFetch).not.toHaveBeenCalled(); }); 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 }); 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', () => { - notificationStore.init(); + store.init(); lastEventSource!.simulate( 'notification', JSON.stringify(makeNotification({ id: 'n1', read: false })) @@ -143,18 +144,18 @@ describe('notificationStore (singleton)', () => { ); mockFetch.mockReset(); - notificationStore.optimisticMarkAllRead(); + store.optimisticMarkAllRead(); - expect(notificationStore.unreadCount).toBe(0); - expect(notificationStore.notifications.every((n) => n.read)).toBe(true); + expect(store.unreadCount).toBe(0); + expect(store.notifications.every((n) => n.read)).toBe(true); 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 () => { mockFetch.mockResolvedValue(new Response(null, { status: 401 })); - notificationStore.init(); + store.init(); const es = lastEventSource!; 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 () => { - notificationStore.init(); + store.init(); const es = lastEventSource!; 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 () => { - notificationStore.init(); + store.init(); const es = lastEventSource!; 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 () => { mockFetch.mockResolvedValue(new Response(null, { status: 401 })); - notificationStore.init(); + store.init(); const es = lastEventSource!; 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 () => { - notificationStore.init(); + store.init(); const es = lastEventSource!; es.readyState = MockEventSource.CONNECTING; @@ -213,7 +214,7 @@ describe('notificationStore onerror handler', () => { }); it('resets error count after a successful reconnect (onopen)', async () => { - notificationStore.init(); + store.init(); const es = lastEventSource!; es.readyState = MockEventSource.CONNECTING; diff --git a/frontend/src/lib/notification/notifications.svelte.ts b/frontend/src/lib/notification/notifications.svelte.ts index 1e52cfe3..8d975d0e 100644 --- a/frontend/src/lib/notification/notifications.svelte.ts +++ b/frontend/src/lib/notification/notifications.svelte.ts @@ -1,121 +1,161 @@ +import { getContext, setContext } from 'svelte'; import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications'; export type { NotificationItem }; -let notifications = $state([]); -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; -}; +export const NOTIFICATION_KEY = Symbol('notification'); -async function fetchNotifications(): Promise { - 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); - } +export interface NotificationStore { + readonly notifications: NotificationItem[]; + readonly unreadCount: number; + fetchNotifications(): Promise; + fetchUnreadCount(): Promise; + optimisticMarkRead(id: string): void; + optimisticMarkAllRead(): void; + init(): void; + 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 { - 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; +export function createNotificationStore(): NotificationStore { + let notifications = $state([]); + 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; }; - eventSource.onerror = async () => { - if (eventSource?.readyState === EventSource.CLOSED) { - const res = await fetch('/api/notifications/unread-count'); - if (res.status === 401) navigate('/login'); - return; + + async function fetchNotifications(): Promise { + 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); } - errorCount += 1; - if (errorCount >= 3) { + } + + async function fetchUnreadCount(): Promise { + 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 = 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; - refCount -= 1; - if (refCount === 0) { - eventSource?.close(); - eventSource = null; +/** + * Create a notification store and put it on the context. Call once high in the + * tree (root +layout.svelte). Descendants read it via getNotificationStore(). + */ +export function provideNotificationStore(): NotificationStore { + const store = createNotificationStore(); + setContext(NOTIFICATION_KEY, store); + return store; +} + +export function getNotificationStore(): NotificationStore { + let store: NotificationStore | undefined; + try { + store = getContext(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 -}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 136eb060..1ca23440 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte'; import UserMenu from './UserMenu.svelte'; import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte'; import { provideConfirmService } from '$lib/shared/services/confirm.svelte'; +import { provideNotificationStore } from '$lib/notification/notifications.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; let { children, data } = $props(); @@ -18,6 +19,11 @@ let { children, data } = $props(); // ConfirmDialog below reads it via getConfirmService() and renders the . 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 // surface the BulkSelectionBar. Without this the selection silently follows // the user to /persons / /admin etc. and reappears as a stale 3-doc count diff --git a/frontend/src/routes/aktivitaeten/+page.svelte b/frontend/src/routes/aktivitaeten/+page.svelte index 7246e51e..06a0ff6b 100644 --- a/frontend/src/routes/aktivitaeten/+page.svelte +++ b/frontend/src/routes/aktivitaeten/+page.svelte @@ -3,7 +3,10 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { page, navigating } from '$app/state'; 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 ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte'; import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte'; @@ -26,9 +29,11 @@ interface 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 -// set if the singleton hasn't populated yet. +// set if the store hasn't populated yet. onMount(() => { notificationStore.init(); }); @@ -59,7 +64,7 @@ const seedUnread = $derived( })) ); -// 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. const unread = $derived( notificationStore.notifications.length > 0 ? liveUnread : seedUnread diff --git a/frontend/src/routes/aktivitaeten/page.svelte.test.ts b/frontend/src/routes/aktivitaeten/page.svelte.test.ts index def1980f..e9b90c4e 100644 --- a/frontend/src/routes/aktivitaeten/page.svelte.test.ts +++ b/frontend/src/routes/aktivitaeten/page.svelte.test.ts @@ -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 { page } from 'vitest/browser'; +import { createNotificationStore, NOTIFICATION_KEY } from '$lib/notification/notifications.svelte'; const mockNavigating = { type: null }; const mockPage = { url: new URL('http://localhost/aktivitaeten') }; @@ -28,19 +29,35 @@ vi.mock('$app/navigation', () => ({ onNavigate: () => () => {} })); -vi.mock('$lib/notification/notifications.svelte', () => ({ - notificationStore: { - notifications: [], - init: vi.fn(), - destroy: vi.fn(), - markRead: vi.fn(), - markAllRead: vi.fn() - } -})); +// The Chronik page's onMount calls store.init(), opening an EventSource and +// fetching the unread count. Stub both so no real network / 401 → /login fires. +class NoopEventSource { + static CLOSED = 2; + readyState = 0; + onopen: (() => void) | null = null; + 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'); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const notificationContext = () => new Map([[NOTIFICATION_KEY, createNotificationStore()]]); const baseData = (overrides: Record = {}) => ({ filter: 'alle' as const, @@ -52,13 +69,16 @@ const baseData = (overrides: Record = {}) => ({ describe('aktivitaeten page', () => { 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(); }); 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 const main = document.querySelector('main'); @@ -69,7 +89,7 @@ describe('aktivitaeten page', () => { }); 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 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 () => { - render(AktivitaetenPage, { props: { data: baseData() } }); + render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } }); const empty = document.querySelector('[data-testid="chronik-empty-state"]'); 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 () => { render(AktivitaetenPage, { + context: notificationContext(), props: { data: baseData({ filter: 'fuer-dich' as const, @@ -114,6 +135,7 @@ describe('aktivitaeten page', () => { it('renders the timeline when displayFeed is non-empty', async () => { render(AktivitaetenPage, { + context: notificationContext(), props: { data: baseData({ 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 () => { render(AktivitaetenPage, { + context: notificationContext(), props: { data: baseData({ filter: 'transkription' as const }) } });