Files
familienarchiv/frontend/src/lib/notification/notifications.svelte.spec.ts
Marcel ad067d2e0e 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>
2026-06-03 11:38:22 +02:00

236 lines
6.6 KiB
TypeScript

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 { createNotificationStore } = await import('./notifications.svelte');
let store: ReturnType<typeof createNotificationStore>;
let navigateSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
lastEventSource = null;
eventSourceCount = 0;
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> = {}): 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('notification store', () => {
it('opens a single EventSource across multiple init() calls', () => {
store.init();
store.init();
store.init();
expect(eventSourceCount).toBe(1);
});
it('closes the EventSource only after every init() is matched with destroy()', () => {
store.init();
store.init();
const es = lastEventSource!;
store.destroy();
expect(es.close).not.toHaveBeenCalled();
store.destroy();
expect(es.close).toHaveBeenCalledTimes(1);
});
it('reopens a fresh EventSource after full teardown', () => {
store.init();
store.destroy();
store.init();
expect(eventSourceCount).toBe(2);
});
it('SSE notification event prepends notification and increments unreadCount', () => {
store.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(store.notifications[0].id).toBe('sse-1');
expect(store.unreadCount).toBe(1);
});
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
store.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
store.optimisticMarkRead('sse-1');
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', () => {
store.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
store.optimisticMarkRead('sse-1');
expect(store.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
store.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
);
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n2', read: false }))
);
mockFetch.mockReset();
store.optimisticMarkAllRead();
expect(store.unreadCount).toBe(0);
expect(store.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
});
});
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 }));
store.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 () => {
store.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 () => {
store.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 }));
store.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 () => {
store.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 () => {
store.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();
});
});