Files
TREK/client/src/components/SystemNotices/SystemNoticeModal.test.tsx
T
jubnl 293506217e feat(notices): add system notice infrastructure
Server-side notice registry with per-user condition evaluation (firstLogin,
existingUserBeforeVersion, addonEnabled, dateWindow, role, custom).
Notices are sorted by priority then severity, filtered against dismissals
stored in a new user_notice_dismissals table, and served via
GET /api/system-notices/active + POST /api/system-notices/:id/dismiss.

Client renders notices through a host component that partitions by
display type (modal / banner / toast). The modal renderer supports
multi-page pagination with directional slide transitions, keyboard
navigation, and correct dismiss-all semantics on CTA / X / ESC.
Dismissals are optimistic with a single background retry.

Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features),
onboarding welcome modal, and full i18n coverage across 15 languages.
The /journey route is addon-gated on both client and server.

Also includes: unit + integration test suites, registry integrity test
that validates action CTA IDs against client source, and technical
documentation in docs/system-notices.md.
2026-04-16 14:36:33 +02:00

369 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act } from '@testing-library/react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
import { ModalRenderer } from './SystemNoticeModal';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
// Stub react-markdown to avoid async chunk issues in tests
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
}));
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
function makeNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
return {
id: 'test-notice-1',
display: 'modal',
severity: 'info',
titleKey: 'Test Title',
bodyKey: 'Test body text',
dismissible: true,
...overrides,
};
}
/**
* Advance fake timers past the grace delay (2× rAF fallback → each is a
* setTimeout(0), then 500ms). All three timers fire in sequence with
* runAllTimers() — no need to advance exact milliseconds.
*/
async function flushGraceDelay() {
await act(async () => {
vi.runAllTimers();
});
}
describe('ModalRenderer', () => {
beforeEach(() => {
server.use(
http.post('/api/system-notices/:id/dismiss', () => {
return new HttpResponse(null, { status: 204 });
}),
);
useSystemNoticeStore.setState({ notices: [], loaded: true });
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
document.body.style.overflow = '';
});
it('FE-SN-MODAL-001: renders title and body after grace delay', async () => {
const notice = makeNotice();
render(<ModalRenderer notices={[notice]} />);
// Before delay fires: dialog present but body not yet visible (class-based)
expect(screen.getByRole('dialog')).toBeTruthy();
await flushGraceDelay();
expect(screen.getByText('Test Title')).toBeTruthy();
expect(screen.getByText('Test body text')).toBeTruthy();
});
it('FE-SN-MODAL-002: dismiss button calls store.dismiss(id)', async () => {
const notice = makeNotice();
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
const dismissBtn = screen.getByLabelText('Dismiss');
await act(async () => {
fireEvent.click(dismissBtn);
});
expect(dismissSpy).toHaveBeenCalledWith('test-notice-1');
});
it('FE-SN-MODAL-003: non-dismissible critical notice hides dismiss affordance', async () => {
const notice = makeNotice({ severity: 'critical', dismissible: false });
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.queryByLabelText('Dismiss')).toBeNull();
expect(screen.queryByText('Not now')).toBeNull();
});
it('FE-SN-MODAL-004: ESC key does not close non-dismissible notice', async () => {
const notice = makeNotice({ severity: 'critical', dismissible: false });
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(dismissSpy).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeTruthy();
});
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
await act(async () => {
fireEvent.click(ctaBtn);
});
expect(dismissSpy).toHaveBeenCalledWith('n-a');
expect(dismissSpy).toHaveBeenCalledWith('n-b');
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-006: modal backdrop has opacity-0 class before grace delay fires', () => {
const notice = makeNotice();
const { container } = render(<ModalRenderer notices={[notice]} />);
// Dialog is in DOM, backdrop has opacity-0 before timers fire
expect(screen.getByRole('dialog')).toBeTruthy();
const backdrop = container.querySelector('[role="presentation"]');
expect(backdrop?.className).toContain('opacity-0');
});
it('FE-SN-MODAL-007: body params are interpolated before rendering', async () => {
const notice = makeNotice({
bodyKey: 'Hello {name}, welcome to {app}',
bodyParams: { name: 'Alice', app: 'TREK' },
});
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
});
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
const { container } = render(<ModalRenderer notices={[]} />);
expect(container.firstChild).toBeNull();
});
// ── Multipage (pager) ──────────────────────────────────────────────────────
it('FE-SN-MODAL-009: pager is hidden when only one notice is present', async () => {
const notice = makeNotice();
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.queryByLabelText('Previous notice')).toBeNull();
expect(screen.queryByLabelText('Next notice')).toBeNull();
});
it('FE-SN-MODAL-010: pager shows counter and dots for multiple notices', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('1 / 3')).toBeTruthy();
expect(screen.getByLabelText('Go to notice 1')).toBeTruthy();
expect(screen.getByLabelText('Go to notice 2')).toBeTruthy();
expect(screen.getByLabelText('Go to notice 3')).toBeTruthy();
expect(screen.getByLabelText('Previous notice')).toBeTruthy();
expect(screen.getByLabelText('Next notice')).toBeTruthy();
});
it('FE-SN-MODAL-011: next button advances to the next notice; prev returns', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('1 / 3')).toBeTruthy();
expect(screen.getByText('Notice A')).toBeTruthy();
// Navigate to page 2
await act(async () => {
fireEvent.click(screen.getByLabelText('Next notice'));
});
await flushGraceDelay();
expect(screen.getByText('2 / 3')).toBeTruthy();
expect(screen.getByText('Notice B')).toBeTruthy();
// Navigate back to page 1
await act(async () => {
fireEvent.click(screen.getByLabelText('Previous notice'));
});
await flushGraceDelay();
expect(screen.getByText('1 / 3')).toBeTruthy();
expect(screen.getByText('Notice A')).toBeTruthy();
});
it('FE-SN-MODAL-012: ArrowRight / ArrowLeft keys navigate between pages', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
await act(async () => {
fireEvent.keyDown(document, { key: 'ArrowRight' });
});
await flushGraceDelay();
expect(screen.getByText('Notice B')).toBeTruthy();
await act(async () => {
fireEvent.keyDown(document, { key: 'ArrowLeft' });
});
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
});
it('FE-SN-MODAL-013: clicking a dot navigates directly to that page', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
// Click third dot
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 3'));
});
await flushGraceDelay();
expect(screen.getByText('3 / 3')).toBeTruthy();
expect(screen.getByText('Notice C')).toBeTruthy();
});
it('FE-SN-MODAL-014: non-dismissible notice locks the pager (prev/next/dots disabled)', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A', dismissible: false }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
const prevBtn = screen.getByLabelText('Previous notice') as HTMLButtonElement;
const nextBtn = screen.getByLabelText('Next notice') as HTMLButtonElement;
const dot2 = screen.getByLabelText('Go to notice 2') as HTMLButtonElement;
expect(prevBtn.disabled).toBe(true);
expect(nextBtn.disabled).toBe(true);
expect(dot2.disabled).toBe(true);
// Arrow keys should also be blocked
await act(async () => {
fireEvent.keyDown(document, { key: 'ArrowRight' });
});
// Still on page 1 (no grace delay needed because page didn't change)
expect(screen.getByText('1 / 2')).toBeTruthy();
});
it('FE-SN-MODAL-015: dismissing a notice does not skip the next one (regression)', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
const noticeC = makeNotice({ id: 'n-c', titleKey: 'Notice C' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB, noticeC], loaded: true });
const { rerender } = render(<ModalRenderer notices={[noticeA, noticeB, noticeC]} />);
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
expect(screen.getByText('1 / 3')).toBeTruthy();
// Dismiss notice A — store shrinks, parent re-renders with [B, C]
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
});
await flushGraceDelay();
// Must show B (idx=0), not C (idx=1 — the old buggy behavior)
expect(screen.getByText('Notice B')).toBeTruthy();
expect(screen.getByText('1 / 2')).toBeTruthy();
});
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
});
expect(dismissSpy).toHaveBeenCalledWith('n-a');
expect(dismissSpy).toHaveBeenCalledWith('n-b');
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(dismissSpy).toHaveBeenCalledWith('n-a');
expect(dismissSpy).toHaveBeenCalledWith('n-b');
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-016: dismissing the only remaining notice closes the modal', async () => {
const notice = makeNotice({ id: 'solo', titleKey: 'Solo Notice' });
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const { rerender, container } = render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.getByText('Solo Notice')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [], loaded: true });
rerender(<ModalRenderer notices={[]} />);
});
expect(container.firstChild).toBeNull();
});
});