Replaces the per-component createNotificationStream() factory with a shared $lib/stores/notifications.svelte.ts singleton. Ref-counted init()/destroy() ensures one EventSource per tab no matter how many consumers mount simultaneously. Motivation: the /chronik "Für dich" box (#285) needs the same live-arrival stream that NotificationBell already consumes. Two factories would open two SSE connections per tab — this refactor avoids the silent regression before it ships. - New: src/lib/stores/notifications.svelte.ts (module state, refcount) - New: src/lib/stores/notifications.svelte.spec.ts (proves single EventSource across multiple consumers + ref-counted teardown) - Deleted: src/lib/hooks/useNotificationStream.svelte.ts (factory) - Deleted: src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts - NotificationBell now imports the singleton Part of #285. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
3.0 KiB
TypeScript
109 lines
3.0 KiB
TypeScript
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 {
|
|
onopen: (() => void) | null = null;
|
|
onerror: (() => 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 } = await import('./notifications.svelte');
|
|
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
|
lastEventSource = null;
|
|
eventSourceCount = 0;
|
|
__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);
|
|
});
|
|
});
|