import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { NotificationItem } from '$lib/utils/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) | null = null; close = vi.fn(); private listeners: Record 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; 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 { 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(); }); });