150 lines
5.2 KiB
TypeScript
150 lines
5.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { act, cleanup, render, screen, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { DashboardEvent } from '../../packages/api-contracts/src/index.js';
|
|
import {
|
|
NotificationBell,
|
|
NotificationProvider,
|
|
NotificationViewport,
|
|
notificationForEvent,
|
|
shouldToastForEvent,
|
|
} from '../../apps/dashboard/src/app/notifications.js';
|
|
|
|
class FakeRealtime {
|
|
private listener?: (event: DashboardEvent) => void;
|
|
|
|
subscribe(listener: (event: DashboardEvent) => void): () => void {
|
|
this.listener = listener;
|
|
return () => { this.listener = undefined; };
|
|
}
|
|
|
|
emit(event: DashboardEvent): void {
|
|
this.listener?.(event);
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
globalThis.localStorage.clear();
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('dashboard notifications', () => {
|
|
it('turns actionable realtime events into useful notification copy', () => {
|
|
expect(notificationForEvent({
|
|
id: 'reply-1',
|
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
|
type: 'reply.received',
|
|
payload: { conversationId: 'room/one', replyId: 'message-1', sender: 'alice', preview: 'Sounds good!' },
|
|
})).toMatchObject({
|
|
title: 'New reply from u/alice',
|
|
message: 'Sounds good!',
|
|
tone: 'success',
|
|
href: '/conversations/room%2Fone',
|
|
});
|
|
|
|
expect(notificationForEvent({
|
|
id: 'log-1',
|
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
|
type: 'log.created',
|
|
payload: { level: 'info', message: 'Routine detail' },
|
|
})).toBeNull();
|
|
});
|
|
|
|
it('shows new events as toasts and keeps them in a readable notification centre', async () => {
|
|
const realtime = new FakeRealtime();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<MemoryRouter>
|
|
<NotificationProvider realtime={realtime}>
|
|
<NotificationBell />
|
|
<NotificationViewport />
|
|
</NotificationProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
|
act(() => realtime.emit({
|
|
id: 'reply-1',
|
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
|
type: 'reply.received',
|
|
payload: { conversationId: 'conversation-1', replyId: 'message-1', sender: 'alice', preview: 'Sounds good!' },
|
|
}));
|
|
|
|
const toast = screen.getByRole('status');
|
|
expect(toast).toHaveTextContent('New reply from u/alice');
|
|
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Notifications, 1 unread' }));
|
|
const centre = screen.getByLabelText('Notifications', { selector: 'aside' });
|
|
expect(centre).toHaveClass('is-open');
|
|
expect(within(centre).getByText('Sounds good!')).toBeInTheDocument();
|
|
expect(within(centre).getByRole('link', { name: /New reply from u\/alice/ })).toHaveAttribute(
|
|
'href',
|
|
'/conversations/conversation-1',
|
|
);
|
|
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
|
|
|
await user.click(within(centre).getByRole('button', { name: 'Clear all' }));
|
|
expect(within(centre).getByText('All quiet')).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps routine sync completions in the notification centre without showing a toast', async () => {
|
|
const realtime = new FakeRealtime();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<MemoryRouter>
|
|
<NotificationProvider realtime={realtime}>
|
|
<NotificationBell />
|
|
<NotificationViewport />
|
|
</NotificationProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
const syncEvent: DashboardEvent = {
|
|
id: 'sync-1',
|
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
|
type: 'sync.completed',
|
|
payload: { syncId: 'conversation-sync-1', kind: 'conversations', processed: 12 },
|
|
};
|
|
expect(shouldToastForEvent(syncEvent)).toBe(false);
|
|
act(() => realtime.emit(syncEvent));
|
|
|
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Notifications, 1 unread' }));
|
|
expect(within(screen.getByLabelText('Notifications', { selector: 'aside' })).getByText('Conversations sync complete'))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps important event toasts visible until they are dismissed', async () => {
|
|
vi.useFakeTimers();
|
|
const realtime = new FakeRealtime();
|
|
render(
|
|
<MemoryRouter>
|
|
<NotificationProvider realtime={realtime}>
|
|
<NotificationViewport />
|
|
</NotificationProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
act(() => realtime.emit({
|
|
id: 'media-1',
|
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
|
type: 'media.received',
|
|
payload: { conversationId: 'conversation-1', sender: 'alice', count: 1 },
|
|
}));
|
|
expect(screen.getByRole('status')).toHaveTextContent('Image received from u/alice');
|
|
|
|
act(() => vi.advanceTimersByTime(60_000));
|
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
|
|
act(() => screen.getByRole('button', { name: 'Dismiss Image received from u/alice' }).click());
|
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
});
|
|
});
|