From 14ee688ff37318ffd8a1ae7aa8c37d56ecee7c14 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Wed, 20 May 2026 08:20:33 +0530 Subject: [PATCH] test(settings): backfill unit tests for team / billing / notifications panels (#1870) (#2089) --- .../settings/panels/BillingPanel.test.tsx | 90 +++++++++ .../settings/panels/MessagingPanel.test.tsx | 166 +++++++++++++++++ .../panels/NotificationRoutingPanel.test.tsx | 158 ++++++++++++++++ .../panels/NotificationsPanel.test.tsx | 173 ++++++++++++++++++ .../settings/panels/TeamInvitesPanel.test.tsx | 149 +++++++++++++++ .../panels/TeamManagementPanel.test.tsx | 160 ++++++++++++++++ .../settings/panels/TeamMembersPanel.test.tsx | 157 ++++++++++++++++ .../settings/panels/TeamPanel.test.tsx | 173 ++++++++++++++++++ 8 files changed, 1226 insertions(+) create mode 100644 app/src/components/settings/panels/BillingPanel.test.tsx create mode 100644 app/src/components/settings/panels/MessagingPanel.test.tsx create mode 100644 app/src/components/settings/panels/NotificationRoutingPanel.test.tsx create mode 100644 app/src/components/settings/panels/NotificationsPanel.test.tsx create mode 100644 app/src/components/settings/panels/TeamInvitesPanel.test.tsx create mode 100644 app/src/components/settings/panels/TeamManagementPanel.test.tsx create mode 100644 app/src/components/settings/panels/TeamMembersPanel.test.tsx create mode 100644 app/src/components/settings/panels/TeamPanel.test.tsx diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx new file mode 100644 index 000000000..6b3bdead3 --- /dev/null +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -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 }) => ( + + ), +})); + +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('', () => { + 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(); + + // 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(); + + // 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(); + + // 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(); + 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); + }); +}); diff --git a/app/src/components/settings/panels/MessagingPanel.test.tsx b/app/src/components/settings/panels/MessagingPanel.test.tsx new file mode 100644 index 000000000..a1eeb5113 --- /dev/null +++ b/app/src/components/settings/panels/MessagingPanel.test.tsx @@ -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 }) =>
{title}
, +})); + +vi.mock('../../channels/ChannelSetupModal', () => ({ + default: ({ + definition, + onClose, + }: { + definition: { id: string; display_name: string }; + onClose: () => void; + }) => ( +
+

Setup: {definition.display_name}

+ +
+ ), +})); + +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[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( + + + + + + ); +} + +describe('', () => { + 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')); + }); +}); diff --git a/app/src/components/settings/panels/NotificationRoutingPanel.test.tsx b/app/src/components/settings/panels/NotificationRoutingPanel.test.tsx new file mode 100644 index 000000000..e91907c50 --- /dev/null +++ b/app/src/components/settings/panels/NotificationRoutingPanel.test.tsx @@ -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 }) =>
{title}
, +})); + +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( + + + + ); +} + +function defaultSettings() { + return { enabled: true, importance_threshold: 0.3, route_to_orchestrator: true }; +} + +describe('', () => { + 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(); + }); +}); diff --git a/app/src/components/settings/panels/NotificationsPanel.test.tsx b/app/src/components/settings/panels/NotificationsPanel.test.tsx new file mode 100644 index 000000000..d61a70998 --- /dev/null +++ b/app/src/components/settings/panels/NotificationsPanel.test.tsx @@ -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 }) =>
{title}
, +})); + +const getBypassPrefsMock = vi.fn(); +const setGlobalDndMock = vi.fn(); +vi.mock('../../../services/webviewAccountService', () => ({ + getBypassPrefs: () => getBypassPrefsMock(), + setGlobalDnd: (enabled: boolean) => setGlobalDndMock(enabled), +})); + +function buildStore(preloaded?: Partial) { + 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( + + + + + + ); +} + +describe('', () => { + 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' + ); + }); +}); diff --git a/app/src/components/settings/panels/TeamInvitesPanel.test.tsx b/app/src/components/settings/panels/TeamInvitesPanel.test.tsx new file mode 100644 index 000000000..4fa3117c0 --- /dev/null +++ b/app/src/components/settings/panels/TeamInvitesPanel.test.tsx @@ -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 }) =>
{title}
, +})); + +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 = {}) { + return { + _id: 'inv-active', + code: 'ACTIVE-123', + createdBy: 'user-1', + expiresAt: FUTURE, + maxUses: 0, + currentUses: 0, + usageHistory: [], + ...overrides, + }; +} + +function defaultCoreState(overrides: Record = {}) { + 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( + + + } /> + } /> + + + ); +} + +describe('', () => { + 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()); + }); +}); diff --git a/app/src/components/settings/panels/TeamManagementPanel.test.tsx b/app/src/components/settings/panels/TeamManagementPanel.test.tsx new file mode 100644 index 000000000..496f1e60b --- /dev/null +++ b/app/src/components/settings/panels/TeamManagementPanel.test.tsx @@ -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 }) =>
{title}
, +})); + +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 = {}) { + 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 = {}) { + 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( + + + } /> + + + ); +} + +describe('', () => { + 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')); + }); +}); diff --git a/app/src/components/settings/panels/TeamMembersPanel.test.tsx b/app/src/components/settings/panels/TeamMembersPanel.test.tsx new file mode 100644 index 000000000..fbaff65ca --- /dev/null +++ b/app/src/components/settings/panels/TeamMembersPanel.test.tsx @@ -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 }) =>
{title}
, +})); + +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 = {}) { + 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 = {}) { + 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( + + + } /> + } /> + + + ); +} + +describe('', () => { + 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