refactor: move notification domain to lib/notification/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
200
frontend/src/lib/notification/notifications.svelte.spec.ts
Normal file
200
frontend/src/lib/notification/notifications.svelte.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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 { notificationStore, __resetForTest, __setNavigateForTest } =
|
||||
await import('./notifications.svelte');
|
||||
|
||||
let navigateSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
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> = {}): 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user