test(settings): backfill unit tests for team / billing / notifications panels (#1870) (#2089)

This commit is contained in:
CodeGhost21
2026-05-19 19:50:33 -07:00
committed by GitHub
parent 82df98f419
commit 14ee688ff3
8 changed files with 1226 additions and 0 deletions
@@ -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());
});
});