Files
spoon/apps/next/tests/component/notification-preferences.test.tsx
T

118 lines
3.4 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 { NotificationPreferencesPanel } from '../../src/components/settings/notification-preferences-panel';
// 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: {
getPreferences: 'notifications:getPreferences',
updatePreferences: 'notifications:updatePreferences',
},
},
}));
const { mockUpdatePreferences, mockUseMutation, mockUseQuery } = vi.hoisted(
() => ({
mockUpdatePreferences: vi.fn(),
mockUseMutation: vi.fn(),
mockUseQuery: vi.fn(),
}),
);
vi.mock('convex/react', () => ({
useMutation: mockUseMutation,
useQuery: mockUseQuery,
}));
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Pass-through UI primitives so the switches are deterministically present and
// clickable under jsdom (Radix pointer behavior is flaky here). We exercise the
// panel's logic, not the design-system components.
vi.mock('@spoon/ui', () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Label: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
Switch: ({
checked,
onCheckedChange,
disabled,
...props
}: {
checked?: boolean;
onCheckedChange?: (value: boolean) => void;
disabled?: boolean;
} & ComponentProps<'button'>) => (
<button
role='switch'
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
{...props}
/>
),
}));
const prefs = {
emailEnabled: true,
emailMaintenance: true,
emailAgentTurnFinished: true,
emailAgentNeedsInput: false,
emailSyncFailed: true,
emailConnectionReauth: true,
};
const seed = () => {
mockUpdatePreferences.mockResolvedValue(undefined);
mockUseMutation.mockReturnValue(mockUpdatePreferences);
mockUseQuery.mockImplementation((reference: unknown) =>
reference === api.notifications.getPreferences ? prefs : undefined,
);
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('NotificationPreferencesPanel', () => {
it('renders toggle state from the query values', () => {
seed();
render(<NotificationPreferencesPanel />);
const needsInput = screen.getByRole('switch', {
name: /needs your input/i,
});
expect(needsInput).toHaveAttribute('aria-checked', 'false');
const master = screen.getByRole('switch', { name: /email notifications/i });
expect(master).toHaveAttribute('aria-checked', 'true');
});
it('calls updatePreferences with the flipped field when a toggle changes', () => {
seed();
render(<NotificationPreferencesPanel />);
fireEvent.click(
screen.getByRole('switch', { name: /sync fails/i }),
);
expect(mockUpdatePreferences).toHaveBeenCalledWith({
emailSyncFailed: false,
});
});
it('does not render toggles until the query resolves', () => {
mockUseMutation.mockReturnValue(mockUpdatePreferences);
mockUseQuery.mockReturnValue(undefined);
render(<NotificationPreferencesPanel />);
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
});
});