test(notification): make setNotifications authoritative in bell a11y tests
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m13s

CI showed the single/many a11y tests failing with count 0: init()'s async
fetchUnreadCount resolved to {count:0} AFTER setNotifications() ran,
clobbering the seeded count (the flake Sara predicted in review). Stub
fetch to never settle so the announced count is driven solely by
setNotifications — deterministic, no race. Also rewrites the 'error' test
to seed a count then fail the load and assert the count SURVIVES, so it is
a meaningful state distinct from 'empty' (was byte-identical, flagged by
Felix/Sara/Leonie). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #719.
This commit is contained in:
Marcel
2026-06-03 10:32:00 +02:00
committed by marcel
parent 4db2e97490
commit 27b6d58632

View File

@@ -30,11 +30,12 @@ class NoopEventSource {
beforeEach(() => {
vi.stubGlobal('EventSource', NoopEventSource);
// init()'s fetchUnreadCount() never settles, so the bell's announced count is
// driven solely by setNotifications() — removes the init-fetch-vs-setNotifications
// race (the real fetch would resolve to {count:0} and clobber the seeded count).
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
vi.fn(() => new Promise(() => {}))
);
});
@@ -89,7 +90,6 @@ describe('NotificationBell — rendering', () => {
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();
@@ -101,7 +101,6 @@ describe('NotificationBell — announced unread count across a11y states', () =>
it('single: announces one unread notification', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([makeNotification({ id: 'n1', read: false })]);
await tick();
@@ -114,7 +113,6 @@ describe('NotificationBell — announced unread count across a11y states', () =>
it('many: announces the exact unread count', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false }),
@@ -128,16 +126,21 @@ describe('NotificationBell — announced unread count across a11y states', () =>
expect(unreadBadge().textContent?.trim()).toBe('3');
});
it('error: degrades to a zero announced count when the unread-count load fails', async () => {
it('error: a failed unread-count load does not wipe the announced count', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
renderBell();
const { setNotifications } = renderBell();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false })
]);
await tick();
// A failed load leaves the count at 0; the bell still announces a valid,
// non-urgent state rather than a broken count.
// init()'s fetchUnreadCount rejects; its catch must leave the already-set
// count intact rather than silently zeroing the live region. This is a
// distinct state from "empty" — the count survived a transient load failure.
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 2 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
expect(unreadBadge().textContent?.trim()).toBe('2');
});
});