118 lines
3.4 KiB
TypeScript
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();
|
|
});
|
|
});
|