mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/PageBackButton', () => ({
|
||||
default: ({ label, onClick }: { label: string; onClick: () => void }) => (
|
||||
<button type="button" data-testid="page-back-button" onClick={onClick}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const openUrlMock = vi.fn();
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: (url: string) => openUrlMock(url) }));
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./BillingPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe('<BillingPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
openUrlMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('opens the billing dashboard on mount and shows the post-open status', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
|
||||
// The auto-open effect fires once; while the promise is pending the
|
||||
// panel reports the "opening" status. Once it resolves we settle on
|
||||
// the "idle" copy that tells users the browser should be open.
|
||||
await waitFor(() => {
|
||||
expect(openUrlMock).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('If your browser did not open, use the button above.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces an error message when openUrl rejects on mount', async () => {
|
||||
openUrlMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
|
||||
// First call is the auto-open attempt that rejects -> error copy renders.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('The browser could not be opened automatically. Use the button above.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('re-opens the dashboard when the user clicks the primary button', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
|
||||
// Wait for the mount effect call to complete first so the second call
|
||||
// is unambiguously the click.
|
||||
await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' }));
|
||||
await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(2));
|
||||
expect(openUrlMock).toHaveBeenLastCalledWith('https://tinyhumans.ai/dashboard');
|
||||
});
|
||||
|
||||
it('invokes the navigation back handler from both the header and the inline button', async () => {
|
||||
const Panel = await importPanel();
|
||||
render(<Panel />);
|
||||
await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByTestId('page-back-button'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to settings' }));
|
||||
expect(navigateBack).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import channelConnectionsReducer from '../../../store/channelConnectionsSlice';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../channels/ChannelSetupModal', () => ({
|
||||
default: ({
|
||||
definition,
|
||||
onClose,
|
||||
}: {
|
||||
definition: { id: string; display_name: string };
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div data-testid="channel-setup-modal" data-channel={definition.id}>
|
||||
<p>Setup: {definition.display_name}</p>
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const useChannelDefinitionsMock = vi.fn();
|
||||
vi.mock('../../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => useChannelDefinitionsMock(),
|
||||
}));
|
||||
|
||||
const updatePreferencesMock = vi.fn();
|
||||
vi.mock('../../../services/api/channelConnectionsApi', () => ({
|
||||
channelConnectionsApi: { updatePreferences: (channel: string) => updatePreferencesMock(channel) },
|
||||
}));
|
||||
|
||||
const FIXTURE_DEFINITIONS = [
|
||||
{ id: 'telegram', display_name: 'Telegram', description: 'Chat via Telegram', icon: 'telegram' },
|
||||
{ id: 'discord', display_name: 'Discord', description: 'Chat via Discord', icon: 'discord' },
|
||||
{ id: 'web', display_name: 'Web', description: 'Browser-based chat', icon: 'web' },
|
||||
];
|
||||
|
||||
function buildStore(defaultChannel: 'telegram' | 'discord' | 'web' = 'telegram') {
|
||||
const preloadedState = {
|
||||
channelConnections: {
|
||||
defaultMessagingChannel: defaultChannel,
|
||||
connections: { telegram: {}, discord: {}, web: {} },
|
||||
migrationCompleted: true,
|
||||
},
|
||||
} as unknown as Parameters<typeof configureStore>[0]['preloadedState'];
|
||||
return configureStore({
|
||||
reducer: { channelConnections: channelConnectionsReducer },
|
||||
preloadedState,
|
||||
});
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./MessagingPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderPanel(Panel: React.ComponentType, store = buildStore()) {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<Panel />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<MessagingPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useChannelDefinitionsMock.mockReturnValue({
|
||||
definitions: FIXTURE_DEFINITIONS,
|
||||
loading: false,
|
||||
error: null,
|
||||
refreshDefinitions: vi.fn(),
|
||||
});
|
||||
updatePreferencesMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows the loading state from useChannelDefinitions', async () => {
|
||||
useChannelDefinitionsMock.mockReturnValue({
|
||||
definitions: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refreshDefinitions: vi.fn(),
|
||||
});
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
expect(screen.getByText('Loading channel definitions...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the load error returned by the channel definitions hook', async () => {
|
||||
useChannelDefinitionsMock.mockReturnValue({
|
||||
definitions: [],
|
||||
loading: false,
|
||||
error: 'backend unreachable',
|
||||
refreshDefinitions: vi.fn(),
|
||||
});
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
expect(screen.getByText('backend unreachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one button per definition for the default selector and excludes `web` from connections', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// The default selector shows ALL definitions (telegram, discord, web).
|
||||
const defaultButtons = screen.getAllByRole('button', { name: /^Telegram$|^Discord$|^Web$/ });
|
||||
expect(defaultButtons.map(btn => btn.textContent)).toEqual(
|
||||
expect.arrayContaining(['Telegram', 'Discord', 'Web'])
|
||||
);
|
||||
|
||||
// The "Channel Connections" cards filter out `web` per the panel's
|
||||
// configurableChannels memo, so the configurable rows are only
|
||||
// telegram + discord.
|
||||
const connectionRows = screen.getAllByRole('button', { name: /Chat via (Telegram|Discord)/ });
|
||||
expect(connectionRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('opens the ChannelSetupModal for the clicked channel and closes it', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const telegramRow = screen.getByRole('button', { name: /Chat via Telegram/ });
|
||||
fireEvent.click(telegramRow);
|
||||
|
||||
const modal = await screen.findByTestId('channel-setup-modal');
|
||||
expect(modal).toHaveAttribute('data-channel', 'telegram');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('channel-setup-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking a default-channel button calls updatePreferences for that channel', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// discord is not currently the default; clicking selects it.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discord' }));
|
||||
await waitFor(() => expect(updatePreferencesMock).toHaveBeenCalledWith('discord'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
const fetchStatsMock = vi.fn();
|
||||
const getSettingsMock = vi.fn();
|
||||
const setSettingsMock = vi.fn();
|
||||
vi.mock('../../../services/notificationService', () => ({
|
||||
fetchNotificationStats: () => fetchStatsMock(),
|
||||
getNotificationSettings: (provider: string) => getSettingsMock(provider),
|
||||
setNotificationSettings: (payload: unknown) => setSettingsMock(payload),
|
||||
}));
|
||||
|
||||
const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp'];
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./NotificationRoutingPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderPanel(Panel: React.ComponentType) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Panel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
return { enabled: true, importance_threshold: 0.3, route_to_orchestrator: true };
|
||||
}
|
||||
|
||||
describe('<NotificationRoutingPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchStatsMock.mockResolvedValue({ total: 12, unread: 5, unscored: 2 });
|
||||
getSettingsMock.mockImplementation((provider: string) =>
|
||||
Promise.resolve({ provider, ...defaultSettings() })
|
||||
);
|
||||
setSettingsMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders the pipeline stats card once fetchNotificationStats resolves', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Pipeline stats')).toBeInTheDocument());
|
||||
// total / unread / unscored values appear as separate stat tiles
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the pipeline stats card when fetchNotificationStats rejects', async () => {
|
||||
fetchStatsMock.mockRejectedValueOnce(new Error('stats down'));
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// Settings still load for each provider so the per-provider section
|
||||
// renders, but the stats card is absent.
|
||||
await waitFor(() => expect(getSettingsMock).toHaveBeenCalledTimes(PROVIDERS.length));
|
||||
expect(screen.queryByText('Pipeline stats')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one row per provider with persisted threshold values', async () => {
|
||||
getSettingsMock.mockImplementation((provider: string) =>
|
||||
Promise.resolve({
|
||||
provider,
|
||||
enabled: true,
|
||||
importance_threshold: 0.45,
|
||||
route_to_orchestrator: true,
|
||||
})
|
||||
);
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() => expect(getSettingsMock).toHaveBeenCalledTimes(PROVIDERS.length));
|
||||
// Provider headings render capitalized via CSS but the underlying text
|
||||
// is lowercased; assert against the rendered text directly.
|
||||
for (const provider of PROVIDERS) {
|
||||
expect(screen.getByText(provider)).toBeInTheDocument();
|
||||
}
|
||||
// Each row formats the threshold to 2 decimals — four rows × 0.45.
|
||||
expect(screen.getAllByText('0.45')).toHaveLength(PROVIDERS.length);
|
||||
});
|
||||
|
||||
it('toggling enabled for a provider fires setNotificationSettings', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() => expect(getSettingsMock).toHaveBeenCalledTimes(PROVIDERS.length));
|
||||
|
||||
// Each provider exposes an "Enabled" checkbox; the first one is gmail's.
|
||||
const enabledCheckboxes = screen.getAllByRole('checkbox', { name: 'Enabled' });
|
||||
expect(enabledCheckboxes).toHaveLength(PROVIDERS.length);
|
||||
fireEvent.click(enabledCheckboxes[0]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setSettingsMock).toHaveBeenCalledWith({
|
||||
provider: 'gmail',
|
||||
enabled: false,
|
||||
importance_threshold: 0.3,
|
||||
route_to_orchestrator: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the controls and surfaces a retry hint for providers that fail to load', async () => {
|
||||
getSettingsMock.mockImplementation((provider: string) => {
|
||||
if (provider === 'gmail') return Promise.reject(new Error('boom'));
|
||||
return Promise.resolve({ provider, ...defaultSettings() });
|
||||
});
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('Failed to load settings. Reopen this panel to retry.')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// The four `Enabled` checkboxes still render, but the first row (gmail)
|
||||
// is disabled because its settings never loaded.
|
||||
const enabledCheckboxes = screen.getAllByRole('checkbox', { name: 'Enabled' });
|
||||
expect(enabledCheckboxes[0]).toBeDisabled();
|
||||
// The slack row should still be interactive.
|
||||
expect(enabledCheckboxes[1]).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders the static "How it works" explainer rows', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() => expect(getSettingsMock).toHaveBeenCalled());
|
||||
// The four routing tiers are part of the static explainer and should
|
||||
// render regardless of network state.
|
||||
expect(screen.getByText('Drop')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acknowledge')).toBeInTheDocument();
|
||||
expect(screen.getByText('React')).toBeInTheDocument();
|
||||
expect(screen.getByText('Escalate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import notificationReducer, { type NotificationState } from '../../../store/notificationSlice';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
const getBypassPrefsMock = vi.fn();
|
||||
const setGlobalDndMock = vi.fn();
|
||||
vi.mock('../../../services/webviewAccountService', () => ({
|
||||
getBypassPrefs: () => getBypassPrefsMock(),
|
||||
setGlobalDnd: (enabled: boolean) => setGlobalDndMock(enabled),
|
||||
}));
|
||||
|
||||
function buildStore(preloaded?: Partial<NotificationState>) {
|
||||
return configureStore({
|
||||
reducer: { notifications: notificationReducer },
|
||||
preloadedState: preloaded
|
||||
? {
|
||||
notifications: {
|
||||
items: [],
|
||||
preferences: {
|
||||
messages: true,
|
||||
agents: true,
|
||||
skills: true,
|
||||
system: true,
|
||||
meetings: true,
|
||||
reminders: true,
|
||||
important: true,
|
||||
},
|
||||
integrationItems: [],
|
||||
integrationUnreadCount: 0,
|
||||
integrationLoading: false,
|
||||
integrationError: null,
|
||||
...preloaded,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./NotificationsPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderPanel(Panel: React.ComponentType, store = buildStore()) {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<Panel />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<NotificationsPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBypassPrefsMock.mockResolvedValue({ global_dnd: false });
|
||||
setGlobalDndMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders one toggle per category plus the DND switch', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// The DND toggle starts in a loading skeleton state until getBypassPrefs
|
||||
// resolves; the seven category toggles render synchronously.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Toggle Do Not Disturb')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText('Toggle Messages notifications')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Toggle Agent activity notifications')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Toggle Skills notifications')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Toggle System notifications')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Toggle Meetings notifications')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Toggle Reminders notifications')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Toggle Important events notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects the persisted global_dnd value once getBypassPrefs resolves', async () => {
|
||||
getBypassPrefsMock.mockResolvedValueOnce({ global_dnd: true });
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() => {
|
||||
const dnd = screen.getByLabelText('Toggle Do Not Disturb');
|
||||
expect(dnd).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('toggling DND calls setGlobalDnd and updates the optimistic UI', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const dnd = await screen.findByLabelText('Toggle Do Not Disturb');
|
||||
expect(dnd).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(dnd);
|
||||
await waitFor(() => expect(setGlobalDndMock).toHaveBeenCalledWith(true));
|
||||
expect(dnd).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('rolls back the DND toggle when setGlobalDnd rejects', async () => {
|
||||
setGlobalDndMock.mockRejectedValueOnce(new Error('rpc down'));
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const dnd = await screen.findByLabelText('Toggle Do Not Disturb');
|
||||
fireEvent.click(dnd);
|
||||
// After the rejection, the optimistic flip is undone.
|
||||
await waitFor(() => expect(setGlobalDndMock).toHaveBeenCalled());
|
||||
await waitFor(() => expect(dnd).toHaveAttribute('aria-checked', 'false'));
|
||||
});
|
||||
|
||||
it('clicking a category toggle flips its aria-checked state', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const messagesToggle = screen.getByLabelText('Toggle Messages notifications');
|
||||
// Default preference is `true`; clicking should flip to `false`.
|
||||
expect(messagesToggle).toHaveAttribute('aria-checked', 'true');
|
||||
fireEvent.click(messagesToggle);
|
||||
expect(messagesToggle).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('reads category state from the redux preferences slice', async () => {
|
||||
const Panel = await importPanel();
|
||||
const store = buildStore({
|
||||
preferences: {
|
||||
messages: false,
|
||||
agents: true,
|
||||
skills: false,
|
||||
system: true,
|
||||
meetings: true,
|
||||
reminders: false,
|
||||
important: true,
|
||||
},
|
||||
});
|
||||
renderPanel(Panel, store);
|
||||
|
||||
expect(screen.getByLabelText('Toggle Messages notifications')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
);
|
||||
expect(screen.getByLabelText('Toggle Skills notifications')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
);
|
||||
expect(screen.getByLabelText('Toggle Agent activity notifications')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
navigateToSettings: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
const teamApiMock = { createInvite: vi.fn(), revokeInvite: vi.fn() };
|
||||
vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
|
||||
|
||||
const useCoreStateMock = vi.fn();
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
|
||||
|
||||
const FUTURE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const PAST = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
function makeInvite(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'inv-active',
|
||||
code: 'ACTIVE-123',
|
||||
createdBy: 'user-1',
|
||||
expiresAt: FUTURE,
|
||||
maxUses: 0,
|
||||
currentUses: 0,
|
||||
usageHistory: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCoreState(overrides: Record<string, unknown> = {}) {
|
||||
const refreshTeamInvites = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
snapshot: { currentUser: { _id: 'user-me', activeTeamId: 'team-a' } },
|
||||
teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'ADMIN' }],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {
|
||||
'team-a': [
|
||||
makeInvite(),
|
||||
makeInvite({ _id: 'inv-expired', code: 'EXPIRED-1', expiresAt: PAST }),
|
||||
makeInvite({ _id: 'inv-used', code: 'USED-1', maxUses: 1, currentUses: 1 }),
|
||||
],
|
||||
},
|
||||
refreshTeamInvites,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./TeamInvitesPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderAtRoute(Panel: React.ComponentType, path = '/settings/team/manage/team-a/invites') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/settings/team/manage/:teamId/invites" element={<Panel />} />
|
||||
<Route path="/settings/team/invites" element={<Panel />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<TeamInvitesPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
teamApiMock.createInvite.mockResolvedValue(undefined);
|
||||
teamApiMock.revokeInvite.mockResolvedValue(undefined);
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState());
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
||||
});
|
||||
|
||||
it('renders invite codes and tags them with their state (Expired / Used Up)', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
expect(screen.getByText('ACTIVE-123')).toBeInTheDocument();
|
||||
expect(screen.getByText('EXPIRED-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('USED-1')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Expired').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Used Up')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Generate Invite triggers teamApi.createInvite for the current team', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Invite' }));
|
||||
await waitFor(() => expect(teamApiMock.createInvite).toHaveBeenCalledWith('team-a'));
|
||||
});
|
||||
|
||||
it('hides the Generate button and revoke buttons for non-admin viewers', async () => {
|
||||
useCoreStateMock.mockReturnValue(
|
||||
defaultCoreState({ teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'MEMBER' }] })
|
||||
);
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Generate Invite' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Revoke invite' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy button writes the invite code to the clipboard', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
// Active invite is the first one in the list; its Copy button is the
|
||||
// first "Copy invite code" button.
|
||||
const copyBtns = screen.getAllByRole('button', { name: 'Copy invite code' });
|
||||
fireEvent.click(copyBtns[0]);
|
||||
|
||||
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ACTIVE-123'));
|
||||
});
|
||||
|
||||
it('revoke flow opens the confirmation modal and calls revokeInvite on confirm', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
// Only one active invite → one revoke button.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Revoke invite' }));
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Revoke Invite' });
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(teamApiMock.revokeInvite).toHaveBeenCalledWith('team-a', 'inv-active')
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the empty-state hint when no invites exist', async () => {
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState({ teamInvitesById: { 'team-a': [] } }));
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No invites yet')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
const navigateToSettings = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToSettings,
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
const teamApiMock = { updateTeam: vi.fn(), deleteTeam: vi.fn() };
|
||||
vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
|
||||
|
||||
const useCoreStateMock = vi.fn();
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
|
||||
|
||||
function makeTeam(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'team-a',
|
||||
name: 'Acme Team',
|
||||
slug: 'acme',
|
||||
createdBy: 'user-1',
|
||||
isPersonal: false,
|
||||
maxMembers: 10,
|
||||
subscription: { plan: 'FREE', hasActiveSubscription: false },
|
||||
usage: { dailyTokenLimit: 0, remainingTokens: 0, activeSessionCount: 0 },
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCoreState(overrides: Record<string, unknown> = {}) {
|
||||
const refreshTeams = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
snapshot: { currentUser: { _id: 'user-1', activeTeamId: 'team-a' } },
|
||||
teams: [{ team: makeTeam(), role: 'ADMIN' }],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
refreshTeams,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./TeamManagementPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderAtRoute(Panel: React.ComponentType, teamId = 'team-a') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/settings/team/manage/${teamId}`]}>
|
||||
<Routes>
|
||||
<Route path="/settings/team/manage/:teamId" element={<Panel />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<TeamManagementPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
teamApiMock.updateTeam.mockResolvedValue(undefined);
|
||||
teamApiMock.deleteTeam.mockResolvedValue(undefined);
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState());
|
||||
});
|
||||
|
||||
it('renders the team header and the four management entries for an admin', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
expect(screen.getByText('Acme Team')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Members/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Invites/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Team Settings/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Delete Team/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Delete Team button for personal teams', async () => {
|
||||
useCoreStateMock.mockReturnValue(
|
||||
defaultCoreState({ teams: [{ team: makeTeam({ isPersonal: true }), role: 'ADMIN' }] })
|
||||
);
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Delete Team/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the not-found message when the teamId is unknown', async () => {
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState({ teams: [] }));
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel, 'team-zzz');
|
||||
|
||||
expect(screen.getByText('Team not found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects (navigateBack) when the viewer is not an admin', async () => {
|
||||
useCoreStateMock.mockReturnValue(
|
||||
defaultCoreState({ teams: [{ team: makeTeam(), role: 'MEMBER' }] })
|
||||
);
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
await waitFor(() => expect(navigateBack).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('Members entry navigates to the members sub-route', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Members/ }));
|
||||
expect(navigateToSettings).toHaveBeenCalledWith('team/manage/team-a/members');
|
||||
});
|
||||
|
||||
it('Invites entry navigates to the invites sub-route', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Invites/ }));
|
||||
expect(navigateToSettings).toHaveBeenCalledWith('team/manage/team-a/invites');
|
||||
});
|
||||
|
||||
it('Team Settings opens the edit modal pre-filled with the current name and saves the update', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Team Settings/ }));
|
||||
const nameInput = await screen.findByPlaceholderText('Enter team name');
|
||||
expect(nameInput).toHaveValue('Acme Team');
|
||||
fireEvent.change(nameInput, { target: { value: 'Renamed Team' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(teamApiMock.updateTeam).toHaveBeenCalledWith('team-a', { name: 'Renamed Team' })
|
||||
);
|
||||
});
|
||||
|
||||
it('Delete Team opens the confirmation modal and calls deleteTeam on confirm', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Delete Team/ }));
|
||||
// The modal exposes a second "Delete Team" button as the confirm action.
|
||||
const buttons = await screen.findAllByRole('button', { name: 'Delete Team' });
|
||||
// The last one rendered is the confirmation button inside the modal.
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
await waitFor(() => expect(teamApiMock.deleteTeam).toHaveBeenCalledWith('team-a'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
navigateToSettings: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
const teamApiMock = { changeMemberRole: vi.fn(), removeMember: vi.fn() };
|
||||
vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
|
||||
|
||||
const useCoreStateMock = vi.fn();
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
|
||||
|
||||
function makeMember(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: `mem-${id}`,
|
||||
user: {
|
||||
_id: `user-${id}`,
|
||||
firstName: `First${id}`,
|
||||
lastName: `Last${id}`,
|
||||
username: `user${id}`,
|
||||
},
|
||||
role: 'MEMBER',
|
||||
joinedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCoreState(overrides: Record<string, unknown> = {}) {
|
||||
const refreshTeamMembers = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
snapshot: { currentUser: { _id: 'user-me', activeTeamId: 'team-a' } },
|
||||
teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'ADMIN' }],
|
||||
teamMembersById: {
|
||||
'team-a': [
|
||||
makeMember('me', {
|
||||
_id: 'mem-me',
|
||||
user: { _id: 'user-me', firstName: 'Me', lastName: 'Self', username: 'me' },
|
||||
role: 'ADMIN',
|
||||
}),
|
||||
makeMember('1'),
|
||||
],
|
||||
},
|
||||
teamInvitesById: {},
|
||||
refreshTeamMembers,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./TeamMembersPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderAtRoute(Panel: React.ComponentType, path = '/settings/team/manage/team-a/members') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/settings/team/manage/:teamId/members" element={<Panel />} />
|
||||
<Route path="/settings/team/members" element={<Panel />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<TeamMembersPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
teamApiMock.changeMemberRole.mockResolvedValue(undefined);
|
||||
teamApiMock.removeMember.mockResolvedValue(undefined);
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState());
|
||||
});
|
||||
|
||||
it('renders members and tags the current user with "(You)"', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
expect(screen.getByText('Me Self')).toBeInTheDocument();
|
||||
expect(screen.getByText('First1 Last1')).toBeInTheDocument();
|
||||
expect(screen.getByText('(You)')).toBeInTheDocument();
|
||||
// Two members rendered → count line shows "2 members".
|
||||
expect(screen.getByText('2 members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses activeTeamId when not in the team-management route context', async () => {
|
||||
const refreshTeamMembers = vi.fn().mockResolvedValue(undefined);
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState({ refreshTeamMembers }));
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel, '/settings/team/members');
|
||||
|
||||
await waitFor(() => expect(refreshTeamMembers).toHaveBeenCalledWith('team-a'));
|
||||
});
|
||||
|
||||
it('admin sees a role-change dropdown and a remove button for other members', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
// Other member surfaces a <select> for role and a Remove button.
|
||||
expect(screen.getByRole('button', { name: /Remove First1 Last1/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('changing a member role opens the confirmation and dispatches teamApi.changeMemberRole', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'ADMIN' } });
|
||||
// Confirmation modal renders with a Change Role button.
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Change Role' });
|
||||
fireEvent.click(confirmBtn);
|
||||
await waitFor(() =>
|
||||
expect(teamApiMock.changeMemberRole).toHaveBeenCalledWith('team-a', 'user-1', 'ADMIN')
|
||||
);
|
||||
});
|
||||
|
||||
it('removing a member opens the confirmation modal and fires removeMember on confirm', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Remove First1 Last1/ }));
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Remove Member' });
|
||||
fireEvent.click(confirmBtn);
|
||||
await waitFor(() => expect(teamApiMock.removeMember).toHaveBeenCalledWith('team-a', 'user-1'));
|
||||
});
|
||||
|
||||
it('renders the empty-state hint when the team has no members', async () => {
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState({ teamMembersById: { 'team-a': [] } }));
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No members found')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('non-admin viewers do not see role dropdowns or remove buttons', async () => {
|
||||
useCoreStateMock.mockReturnValue(
|
||||
defaultCoreState({ teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'MEMBER' }] })
|
||||
);
|
||||
const Panel = await importPanel();
|
||||
renderAtRoute(Panel);
|
||||
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Remove /i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
const navigateToTeamManagement = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToTeamManagement,
|
||||
navigateToSettings: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
const teamApiMock = {
|
||||
createTeam: vi.fn(),
|
||||
joinTeam: vi.fn(),
|
||||
switchTeam: vi.fn(),
|
||||
leaveTeam: vi.fn(),
|
||||
};
|
||||
vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
|
||||
|
||||
const useCoreStateMock = vi.fn();
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
|
||||
|
||||
function makeTeam(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: id,
|
||||
name: `Team ${id}`,
|
||||
slug: id,
|
||||
createdBy: 'user-1',
|
||||
isPersonal: false,
|
||||
maxMembers: 10,
|
||||
subscription: { plan: 'FREE', hasActiveSubscription: false },
|
||||
usage: { dailyTokenLimit: 0, remainingTokens: 0, activeSessionCount: 0 },
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCoreState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
snapshot: { currentUser: { _id: 'user-1', activeTeamId: 'team-a' } },
|
||||
teams: [
|
||||
{ team: makeTeam('team-a'), role: 'ADMIN' },
|
||||
{ team: makeTeam('team-b'), role: 'MEMBER' },
|
||||
],
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshTeams: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./TeamPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderPanel(Panel: React.ComponentType) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Panel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<TeamPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
teamApiMock.createTeam.mockResolvedValue(undefined);
|
||||
teamApiMock.joinTeam.mockResolvedValue(undefined);
|
||||
teamApiMock.switchTeam.mockResolvedValue(undefined);
|
||||
teamApiMock.leaveTeam.mockResolvedValue(undefined);
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState());
|
||||
});
|
||||
|
||||
it('renders one row per team and tags the active team', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
expect(screen.getByText('Team team-a')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team team-b')).toBeInTheDocument();
|
||||
// Only the active team shows the "Active" badge.
|
||||
expect(screen.getAllByText('Active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes the teams list on mount', async () => {
|
||||
const refreshTeams = vi.fn().mockResolvedValue(undefined);
|
||||
useCoreStateMock.mockReturnValue(defaultCoreState({ refreshTeams }));
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('disables the Create button until a name is typed and calls createTeam on submit', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const createBtn = screen.getByRole('button', { name: 'Create' });
|
||||
expect(createBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Team name'), { target: { value: 'New Team' } });
|
||||
expect(createBtn).not.toBeDisabled();
|
||||
fireEvent.click(createBtn);
|
||||
await waitFor(() => expect(teamApiMock.createTeam).toHaveBeenCalledWith('New Team'));
|
||||
});
|
||||
|
||||
it('joins a team when the user submits a join code', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Invite code'), {
|
||||
target: { value: 'ABCD-1234' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||
await waitFor(() => expect(teamApiMock.joinTeam).toHaveBeenCalledWith('ABCD-1234'));
|
||||
});
|
||||
|
||||
it('switches to a non-active team when the Switch button is clicked', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// Only team-b has a Switch button (team-a is active).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch' }));
|
||||
await waitFor(() => expect(teamApiMock.switchTeam).toHaveBeenCalledWith('team-b'));
|
||||
});
|
||||
|
||||
it('opens the leave-team confirmation, cancels without firing leaveTeam, then confirms to call it', async () => {
|
||||
// team-b is non-personal and the user is MEMBER, so the Leave button
|
||||
// appears for that row.
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
|
||||
// Confirmation modal now visible.
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(teamApiMock.leaveTeam).not.toHaveBeenCalled();
|
||||
|
||||
// Re-open and confirm this time.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave Team' }));
|
||||
await waitFor(() => expect(teamApiMock.leaveTeam).toHaveBeenCalledWith('team-b'));
|
||||
});
|
||||
|
||||
it('renders the Manage Team button on admin-owned non-personal teams', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const manageBtn = screen.getByRole('button', { name: 'Manage Team' });
|
||||
fireEvent.click(manageBtn);
|
||||
expect(navigateToTeamManagement).toHaveBeenCalledWith('team-a');
|
||||
});
|
||||
|
||||
it('surfaces the localized error when createTeam rejects', async () => {
|
||||
teamApiMock.createTeam.mockRejectedValueOnce(new Error('boom'));
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Team name'), { target: { value: 'New Team' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(screen.getByText('Failed to create team')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user