136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
import type { ComponentProps, ReactNode } from 'react';
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
|
|
import { NotificationBell } from '../../src/components/layout/header/controls/notification-bell';
|
|
|
|
// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on
|
|
// every access, so `===` comparisons in the mocks below would never match.
|
|
vi.mock('@spoon/backend/convex/_generated/api.js', () => ({
|
|
api: {
|
|
notifications: {
|
|
unreadCount: 'notifications:unreadCount',
|
|
listMine: 'notifications:listMine',
|
|
markRead: 'notifications:markRead',
|
|
markAllRead: 'notifications:markAllRead',
|
|
},
|
|
},
|
|
}));
|
|
|
|
const { mockMarkAllRead, mockMarkRead, mockUseConvexAuth, mockUseQuery } =
|
|
vi.hoisted(() => ({
|
|
mockMarkAllRead: vi.fn(),
|
|
mockMarkRead: vi.fn(),
|
|
mockUseConvexAuth: vi.fn(),
|
|
mockUseQuery: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('convex/react', () => ({
|
|
useConvexAuth: mockUseConvexAuth,
|
|
useMutation: (reference: unknown) =>
|
|
reference === api.notifications.markAllRead ? mockMarkAllRead : mockMarkRead,
|
|
useQuery: mockUseQuery,
|
|
}));
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock('sonner', () => ({
|
|
toast: { error: vi.fn(), success: vi.fn() },
|
|
}));
|
|
|
|
// Render the dropdown primitives as pass-through wrappers so the menu content
|
|
// is deterministically present under jsdom (Radix pointer behavior is flaky
|
|
// here). We are exercising NotificationBell's logic, not Radix.
|
|
vi.mock('@spoon/ui', () => ({
|
|
Button: (props: ComponentProps<'button'>) => <button {...props} />,
|
|
DropdownMenu: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
DropdownMenuContent: ({ children }: { children?: ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DropdownMenuSeparator: () => <hr />,
|
|
}));
|
|
|
|
const notifications = [
|
|
{
|
|
_id: 'notif-1',
|
|
_creationTime: 2,
|
|
userId: 'user-1',
|
|
kind: 'agent_turn_finished',
|
|
title: 'Agent finished its turn',
|
|
body: 'useSend is ready for review.',
|
|
link: '/threads/thread-1',
|
|
createdAt: Date.now() - 60_000,
|
|
},
|
|
{
|
|
_id: 'notif-2',
|
|
_creationTime: 1,
|
|
userId: 'user-1',
|
|
kind: 'sync_failed',
|
|
title: 'Sync failed',
|
|
body: 'Could not sync useSend with upstream.',
|
|
link: '/spoons/spoon-1',
|
|
createdAt: Date.now() - 3_600_000,
|
|
},
|
|
];
|
|
|
|
const seedQueries = (unreadCount: number) => {
|
|
mockUseConvexAuth.mockReturnValue({ isAuthenticated: true, isLoading: false });
|
|
mockUseQuery.mockImplementation((reference: unknown) => {
|
|
if (reference === api.notifications.unreadCount) return unreadCount;
|
|
if (reference === api.notifications.listMine) return notifications;
|
|
return undefined;
|
|
});
|
|
};
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('NotificationBell', () => {
|
|
it('renders the unread badge with the count', () => {
|
|
seedQueries(3);
|
|
render(<NotificationBell />);
|
|
expect(screen.getByText('3')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the notification titles when opened', () => {
|
|
seedQueries(3);
|
|
render(<NotificationBell />);
|
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
|
expect(screen.getByText('Agent finished its turn')).toBeInTheDocument();
|
|
expect(screen.getByText('Sync failed')).toBeInTheDocument();
|
|
});
|
|
|
|
it('marks a notification read when it is clicked', () => {
|
|
seedQueries(3);
|
|
render(<NotificationBell />);
|
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
|
fireEvent.click(screen.getByText('Agent finished its turn'));
|
|
expect(mockMarkRead).toHaveBeenCalledWith({ notificationId: 'notif-1' });
|
|
});
|
|
|
|
it('marks all read when the action is clicked', () => {
|
|
seedQueries(3);
|
|
render(<NotificationBell />);
|
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /mark all read/i }));
|
|
expect(mockMarkAllRead).toHaveBeenCalled();
|
|
});
|
|
|
|
it('hides the badge when there are no unread notifications', () => {
|
|
seedQueries(0);
|
|
render(<NotificationBell />);
|
|
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
|
});
|
|
});
|