Files
familienarchiv/frontend/src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
Marcel 8739511058 test(notifications): add SSE event handling tests for useNotificationStream
Adds MockEventSource.simulate() helper and two tests covering:
- unread notification via SSE prepends to list and increments unreadCount
- read notification via SSE adds to list but does not increment unreadCount

Fixes @saraholt: "SSE event handling not tested"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:09:26 +02:00

143 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '../useNotificationStream.svelte';
// Track the last created EventSource instance
let lastEventSource: {
close: ReturnType<typeof vi.fn>;
onopen: (() => void) | null;
onerror: (() => void) | null;
simulate: (type: string, data: string) => void;
} | null = null;
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
constructor() {
// 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);
// Import after stubs are set up
const { createNotificationStream } = await import('../useNotificationStream.svelte');
beforeEach(() => {
mockFetch.mockReset();
lastEventSource = null;
});
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
type: 'REPLY',
actorName: 'Hans',
documentId: 'doc-1',
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: new Date().toISOString(),
...overrides
};
}
describe('createNotificationStream', () => {
it('starts with empty notifications and zero unreadCount', () => {
const stream = createNotificationStream();
expect(stream.notifications).toHaveLength(0);
expect(stream.unreadCount).toBe(0);
});
it('fetchUnreadCount updates unreadCount from API', async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ count: 3 }), { status: 200 }));
const stream = createNotificationStream();
await stream.fetchUnreadCount();
expect(stream.unreadCount).toBe(3);
});
it('fetchNotifications populates notifications from API', async () => {
const items = [makeNotification()];
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ content: items }), { status: 200 })
);
const stream = createNotificationStream();
await stream.fetchNotifications();
expect(stream.notifications).toHaveLength(1);
expect(stream.notifications[0].id).toBe('n1');
});
it('markRead marks notification as read and decrements unreadCount', async () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }), { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const stream = createNotificationStream();
await stream.fetchUnreadCount();
const notification = makeNotification({ read: false });
await stream.markRead(notification);
expect(notification.read).toBe(true);
expect(stream.unreadCount).toBe(1);
});
it('markAllRead calls the API and resets unreadCount', async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
const stream = createNotificationStream();
await stream.markAllRead();
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(stream.unreadCount).toBe(0);
});
it('destroy closes the EventSource', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
expect(lastEventSource).not.toBeNull();
stream.destroy();
expect(lastEventSource!.close).toHaveBeenCalled();
});
it('SSE notification event prepends notification and increments unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(stream.notifications).toHaveLength(1);
expect(stream.notifications[0].id).toBe('sse-1');
expect(stream.unreadCount).toBe(1);
});
it('SSE notification event with read:true does not increment unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
const notification = makeNotification({ id: 'sse-2', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(stream.notifications).toHaveLength(1);
expect(stream.unreadCount).toBe(0);
});
});