mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
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.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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 { BannerRenderer } from './SystemNoticeBanner';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
function makeBanner(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'banner-1',
|
||||
display: 'banner',
|
||||
severity: 'info',
|
||||
titleKey: 'Maintenance notice',
|
||||
bodyKey: 'System will be down briefly.',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BannerRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.style.removeProperty('--banner-stack-h');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-001: renders banner with correct title and body', async () => {
|
||||
const notice = makeBanner();
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.getByText('System will be down briefly.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeBanner();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const dismissBtn = screen.getByLabelText(/Dismiss/);
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('banner-1');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-003: two banners stack correctly', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'First notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('First notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only last 2 shown)', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Oldest notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Newest notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2, n3]} />);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Oldest notice')).toBeNull();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
expect(screen.getByText('Newest notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
|
||||
const notice = makeBanner({ severity: 'critical', id: 'crit-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const alertEl = screen.getByRole('alert');
|
||||
expect(alertEl.getAttribute('aria-live')).toBe('assertive');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-006: info banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'info' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-007: warn banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'warn', id: 'warn-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-008: renders nothing when notices array is empty', () => {
|
||||
const { container } = render(<BannerRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-009: non-dismissible banner hides dismiss button', async () => {
|
||||
const notice = makeBanner({ dismissible: false });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Dismiss/)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user