// @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(
,
);
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(
,
);
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(
,
);
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();
});
});