refactor(notification): provide notification store via context + fixture
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
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:
@@ -1,121 +1,161 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
|
||||
|
||||
export type { NotificationItem };
|
||||
|
||||
let notifications = $state<NotificationItem[]>([]);
|
||||
let unreadCount = $state(0);
|
||||
let eventSource: EventSource | null = null;
|
||||
let refCount = 0;
|
||||
let errorCount = 0;
|
||||
let navigate: (url: string) => void = (url) => {
|
||||
window.location.href = url;
|
||||
};
|
||||
export const NOTIFICATION_KEY = Symbol('notification');
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/notifications?size=10');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notifications = data.content ?? [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
export interface NotificationStore {
|
||||
readonly notifications: NotificationItem[];
|
||||
readonly unreadCount: number;
|
||||
fetchNotifications(): Promise<void>;
|
||||
fetchUnreadCount(): Promise<void>;
|
||||
optimisticMarkRead(id: string): void;
|
||||
optimisticMarkAllRead(): void;
|
||||
init(): void;
|
||||
destroy(): void;
|
||||
/** Test-only: seed the notification list and derive the unread count. */
|
||||
setNotifications(items: NotificationItem[]): void;
|
||||
/** Test-only: override the 401 → redirect side-effect. */
|
||||
setNavigate(fn: (url: string) => void): void;
|
||||
}
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
unreadCount = data.count;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch unread count', e);
|
||||
}
|
||||
}
|
||||
|
||||
function optimisticMarkRead(id: string): void {
|
||||
const notification = notifications.find((n) => n.id === id);
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function optimisticMarkAllRead(): void {
|
||||
for (const n of notifications) {
|
||||
n.read = true;
|
||||
}
|
||||
unreadCount = 0;
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
refCount += 1;
|
||||
if (refCount > 1) return;
|
||||
|
||||
fetchUnreadCount();
|
||||
eventSource = new EventSource('/api/notifications/stream');
|
||||
eventSource.addEventListener('notification', (e) => {
|
||||
const notification = parseNotificationEvent((e as MessageEvent).data);
|
||||
if (!notification) return;
|
||||
notifications = [notification, ...notifications];
|
||||
if (!notification.read) unreadCount += 1;
|
||||
});
|
||||
eventSource.onopen = () => {
|
||||
fetchUnreadCount();
|
||||
errorCount = 0;
|
||||
export function createNotificationStore(): NotificationStore {
|
||||
let notifications = $state<NotificationItem[]>([]);
|
||||
let unreadCount = $state(0);
|
||||
let eventSource: EventSource | null = null;
|
||||
let refCount = 0;
|
||||
let errorCount = 0;
|
||||
let navigate: (url: string) => void = (url) => {
|
||||
window.location.href = url;
|
||||
};
|
||||
eventSource.onerror = async () => {
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.status === 401) navigate('/login');
|
||||
return;
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/notifications?size=10');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notifications = data.content ?? [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
errorCount += 1;
|
||||
if (errorCount >= 3) {
|
||||
}
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
unreadCount = data.count;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch unread count', e);
|
||||
}
|
||||
}
|
||||
|
||||
function optimisticMarkRead(id: string): void {
|
||||
const notification = notifications.find((n) => n.id === id);
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function optimisticMarkAllRead(): void {
|
||||
for (const n of notifications) {
|
||||
n.read = true;
|
||||
}
|
||||
unreadCount = 0;
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
refCount += 1;
|
||||
if (refCount > 1) return;
|
||||
|
||||
fetchUnreadCount();
|
||||
eventSource = new EventSource('/api/notifications/stream');
|
||||
eventSource.addEventListener('notification', (e) => {
|
||||
const notification = parseNotificationEvent((e as MessageEvent).data);
|
||||
if (!notification) return;
|
||||
notifications = [notification, ...notifications];
|
||||
if (!notification.read) unreadCount += 1;
|
||||
});
|
||||
eventSource.onopen = () => {
|
||||
fetchUnreadCount();
|
||||
errorCount = 0;
|
||||
};
|
||||
eventSource.onerror = async () => {
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.status === 401) navigate('/login');
|
||||
return;
|
||||
}
|
||||
errorCount += 1;
|
||||
if (errorCount >= 3) {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
errorCount = 0;
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.status === 401) navigate('/login');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (refCount === 0) return;
|
||||
refCount -= 1;
|
||||
if (refCount === 0) {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
errorCount = 0;
|
||||
const res = await fetch('/api/notifications/unread-count');
|
||||
if (res.status === 401) navigate('/login');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get notifications() {
|
||||
return notifications;
|
||||
},
|
||||
get unreadCount() {
|
||||
return unreadCount;
|
||||
},
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead,
|
||||
init,
|
||||
destroy,
|
||||
setNotifications(items: NotificationItem[]): void {
|
||||
notifications = items;
|
||||
unreadCount = items.filter((n) => !n.read).length;
|
||||
},
|
||||
setNavigate(fn: (url: string) => void): void {
|
||||
navigate = fn;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (refCount === 0) return;
|
||||
refCount -= 1;
|
||||
if (refCount === 0) {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
/**
|
||||
* Create a notification store and put it on the context. Call once high in the
|
||||
* tree (root +layout.svelte). Descendants read it via getNotificationStore().
|
||||
*/
|
||||
export function provideNotificationStore(): NotificationStore {
|
||||
const store = createNotificationStore();
|
||||
setContext(NOTIFICATION_KEY, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
export function getNotificationStore(): NotificationStore {
|
||||
let store: NotificationStore | undefined;
|
||||
try {
|
||||
store = getContext<NotificationStore>(NOTIFICATION_KEY);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
|
||||
);
|
||||
}
|
||||
if (!store)
|
||||
throw new Error(
|
||||
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
export function __resetForTest(): void {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
refCount = 0;
|
||||
errorCount = 0;
|
||||
notifications = [];
|
||||
unreadCount = 0;
|
||||
}
|
||||
|
||||
export function __setNavigateForTest(fn: (url: string) => void): void {
|
||||
navigate = fn;
|
||||
}
|
||||
|
||||
export const notificationStore = {
|
||||
get notifications() {
|
||||
return notifications;
|
||||
},
|
||||
get unreadCount() {
|
||||
return unreadCount;
|
||||
},
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead,
|
||||
init,
|
||||
destroy
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user