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>
This commit is contained in:
@@ -39,19 +39,20 @@ vi.stubGlobal('EventSource', MockEventSource);
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { notificationStore, __resetForTest, __setNavigateForTest } =
|
||||
await import('./notifications.svelte');
|
||||
const { createNotificationStore } = await import('./notifications.svelte');
|
||||
|
||||
let navigateSpy: ReturnType<typeof vi.fn>;
|
||||
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();
|
||||
__setNavigateForTest(navigateSpy);
|
||||
__resetForTest();
|
||||
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 {
|
||||
@@ -69,70 +70,70 @@ function makeNotification(overrides: Partial<NotificationItem> = {}): Notificati
|
||||
};
|
||||
}
|
||||
|
||||
describe('notificationStore (singleton)', () => {
|
||||
describe('notification store', () => {
|
||||
it('opens a single EventSource across multiple init() calls', () => {
|
||||
notificationStore.init();
|
||||
notificationStore.init();
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
store.init();
|
||||
store.init();
|
||||
|
||||
expect(eventSourceCount).toBe(1);
|
||||
});
|
||||
|
||||
it('closes the EventSource only after every init() is matched with destroy()', () => {
|
||||
notificationStore.init();
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
|
||||
notificationStore.destroy();
|
||||
store.destroy();
|
||||
expect(es.close).not.toHaveBeenCalled();
|
||||
|
||||
notificationStore.destroy();
|
||||
store.destroy();
|
||||
expect(es.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reopens a fresh EventSource after full teardown', () => {
|
||||
notificationStore.init();
|
||||
notificationStore.destroy();
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
store.destroy();
|
||||
store.init();
|
||||
|
||||
expect(eventSourceCount).toBe(2);
|
||||
});
|
||||
|
||||
it('SSE notification event prepends notification and increments unreadCount', () => {
|
||||
notificationStore.init();
|
||||
store.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);
|
||||
expect(store.notifications[0].id).toBe('sse-1');
|
||||
expect(store.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
||||
|
||||
notificationStore.optimisticMarkRead('sse-1');
|
||||
store.optimisticMarkRead('sse-1');
|
||||
|
||||
expect(notificationStore.notifications[0].read).toBe(true);
|
||||
expect(notificationStore.unreadCount).toBe(0);
|
||||
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', () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const notification = makeNotification({ id: 'sse-1', read: true });
|
||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||
|
||||
notificationStore.optimisticMarkRead('sse-1');
|
||||
store.optimisticMarkRead('sse-1');
|
||||
|
||||
expect(notificationStore.unreadCount).toBe(0);
|
||||
expect(store.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
lastEventSource!.simulate(
|
||||
'notification',
|
||||
JSON.stringify(makeNotification({ id: 'n1', read: false }))
|
||||
@@ -143,18 +144,18 @@ describe('notificationStore (singleton)', () => {
|
||||
);
|
||||
mockFetch.mockReset();
|
||||
|
||||
notificationStore.optimisticMarkAllRead();
|
||||
store.optimisticMarkAllRead();
|
||||
|
||||
expect(notificationStore.unreadCount).toBe(0);
|
||||
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
|
||||
expect(store.unreadCount).toBe(0);
|
||||
expect(store.notifications.every((n) => n.read)).toBe(true);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notificationStore onerror handler', () => {
|
||||
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 }));
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CLOSED;
|
||||
|
||||
@@ -164,7 +165,7 @@ describe('notificationStore onerror handler', () => {
|
||||
});
|
||||
|
||||
it('does not redirect when readyState is CLOSED and session is still valid', async () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CLOSED;
|
||||
|
||||
@@ -174,7 +175,7 @@ describe('notificationStore onerror handler', () => {
|
||||
});
|
||||
|
||||
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
@@ -187,7 +188,7 @@ describe('notificationStore onerror handler', () => {
|
||||
|
||||
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
@@ -200,7 +201,7 @@ describe('notificationStore onerror handler', () => {
|
||||
});
|
||||
|
||||
it('closes but does not redirect after threshold when session is still valid', async () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
@@ -213,7 +214,7 @@ describe('notificationStore onerror handler', () => {
|
||||
});
|
||||
|
||||
it('resets error count after a successful reconnect (onopen)', async () => {
|
||||
notificationStore.init();
|
||||
store.init();
|
||||
const es = lastEventSource!;
|
||||
es.readyState = MockEventSource.CONNECTING;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user