Files
familienarchiv/frontend/src/lib/notification/NotificationBell.svelte.spec.ts
Marcel f13999d006
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m37s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
fix(test): load shared mocks via vi.hoisted, not a static import
CI caught that vi.mock('$app/forms', () => ({ ...formsMock })) with a
static `import * as formsMock` fails: vitest hoists vi.mock above the
import, so the factory references an uninitialised binding
("no top level variables inside"). Load the shared mock module via
`const formsMock = await vi.hoisted(() => import('$mocks/...'))` instead —
the factory may reference a vi.hoisted binding, and the dynamic import runs
at collection time (not in the lazily-invoked factory), so it stays clear
of ADR-012's birpc race and the no-async-mock-factories guard. Applies to
all 5 shared-mock consumers ($app/forms x4, $app/navigation x1). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:52:37 +02:00

137 lines
4.6 KiB
TypeScript

import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationFixture from './notification.test-fixture.svelte';
const formsMock = await vi.hoisted(() => import('$mocks/$app/forms'));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({ ...formsMock }));
// NotificationBell.onMount calls store.init(), which opens an EventSource and
// fetches the unread count. Stub both so no real network or 401 → /login
// navigation fires; the real store + provideNotificationStore() run otherwise.
class NoopEventSource {
static CLOSED = 2;
readyState = 0;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener() {}
close() {}
}
beforeEach(() => {
vi.stubGlobal('EventSource', NoopEventSource);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
const tick = () => new Promise((r) => setTimeout(r, 0));
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n1',
type: 'REPLY',
documentId: 'doc-1',
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: '2026-04-21T10:00:00Z',
actorName: 'Anna',
documentTitle: 'Test Doc',
...overrides
});
type Api = { setNotifications: (items: NotificationItem[]) => void };
function renderBell(): Api {
let api: Api = { setNotifications: () => {} };
render(NotificationFixture, { onReady: (a: Api) => (api = a) });
return api;
}
function bellButton(): HTMLButtonElement {
return document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
}
function unreadBadge(): HTMLElement {
return bellButton().querySelector<HTMLElement>('[aria-live="polite"]')!;
}
describe('NotificationBell — rendering', () => {
it('bell button has cursor-pointer class', async () => {
renderBell();
await tick();
expect(bellButton().classList.contains('cursor-pointer')).toBe(true);
});
});
// AC#5: the bell's announced unread count must hold across the four a11y states.
// The count is announced via the aria-live badge text and the button title /
// aria-label; both must stay consistent as the store's notifications change.
describe('NotificationBell — announced unread count across a11y states', () => {
it('empty: announces no unread count and hides the live badge', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
});
it('single: announces one unread notification', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([makeNotification({ id: 'n1', read: false })]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(false);
expect(unreadBadge().textContent?.trim()).toBe('1');
});
it('many: announces the exact unread count', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false }),
makeNotification({ id: 'n3', read: false })
]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 3 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().textContent?.trim()).toBe('3');
});
it('error: degrades to a zero announced count when the unread-count load fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
renderBell();
await tick();
// A failed load leaves the count at 0; the bell still announces a valid,
// non-urgent state rather than a broken count.
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
});
});