diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx index d4319650f5..2ca448fed0 100644 --- a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx @@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => { it('should render year options', () => { const props = createOptionsProps() - render() + const { container } = render() - const allItems = screen.getAllByRole('listitem') - expect(allItems).toHaveLength(212) + const yearList = container.querySelectorAll('ul')[1] + expect(yearList?.children).toHaveLength(200) }) }) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx new file mode 100644 index 0000000000..791ca0362e --- /dev/null +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.spec.tsx @@ -0,0 +1,103 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' +import { useAppContext } from '@/context/app-context' +import { updateWorkspaceInfo } from '@/service/common' +import EditWorkspaceModal from './index' + +vi.mock('@/context/app-context') +vi.mock('@/service/common') + +describe('EditWorkspaceModal', () => { + const mockOnCancel = vi.fn() + const mockNotify = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: true, + } as unknown as AppContextValue) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + const renderModal = () => render( + + + , + ) + + it('should show current workspace name in the input', async () => { + renderModal() + + expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument() + }) + + it('should let user edit workspace name', async () => { + const user = userEvent.setup() + + renderModal() + + const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i) + await user.clear(input) + await user.type(input, 'New Workspace Name') + + expect(input).toHaveValue('New Workspace Name') + }) + + it('should submit update when confirming as owner', async () => { + const user = userEvent.setup() + const mockAssign = vi.fn() + vi.stubGlobal('location', { ...window.location, assign: mockAssign }) + vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace) + + renderModal() + + const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i) + await user.clear(input) + await user.type(input, 'Renamed Workspace') + await user.click(screen.getByRole('button', { name: /operation\.confirm/i })) + + await waitFor(() => { + expect(updateWorkspaceInfo).toHaveBeenCalledWith({ + url: '/workspaces/info', + body: { name: 'Renamed Workspace' }, + }) + expect(mockAssign).toHaveBeenCalled() + }) + }) + + it('should show error toast when update fails', async () => { + const user = userEvent.setup() + + vi.mocked(updateWorkspaceInfo).mockRejectedValue(new Error('update failed')) + + renderModal() + + await user.click(screen.getByRole('button', { name: /operation\.confirm/i })) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + it('should disable confirm button for non-owners', async () => { + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: false, + } as unknown as AppContextValue) + + renderModal() + + expect(await screen.findByRole('button', { name: /operation\.confirm/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/index.spec.tsx b/web/app/components/header/account-setting/members-page/index.spec.tsx new file mode 100644 index 0000000000..211c44444a --- /dev/null +++ b/web/app/components/header/account-setting/members-page/index.spec.tsx @@ -0,0 +1,194 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace, Member } from '@/models/common' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useProviderContext } from '@/context/provider-context' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { useMembers } from '@/service/use-common' +import MembersPage from './index' + +vi.mock('@/context/app-context') +vi.mock('@/context/global-public-context') +vi.mock('@/context/provider-context') +vi.mock('@/hooks/use-format-time-from-now') +vi.mock('@/service/use-common') + +vi.mock('./edit-workspace-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+
Edit Workspace Modal
+ +
+ ), +})) +vi.mock('./invite-button', () => ({ + default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => ( + + ), +})) +vi.mock('./invite-modal', () => ({ + default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => ( +
+
Invite Modal
+ + +
+ ), +})) +vi.mock('./invited-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+
Invited Modal
+ +
+ ), +})) +vi.mock('./operation', () => ({ + default: () =>
Member Operation
, +})) +vi.mock('./operation/transfer-ownership', () => ({ + default: ({ onOperate }: { onOperate: () => void }) => , +})) +vi.mock('./transfer-ownership-modal', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+
Transfer Ownership Modal
+ +
+ ), +})) + +describe('MembersPage', () => { + const mockRefetch = vi.fn() + const mockFormatTimeFromNow = vi.fn(() => 'just now') + + const mockAccounts: Member[] = [ + { + id: '1', + name: 'Owner User', + email: 'owner@example.com', + avatar: '', + avatar_url: '', + role: 'owner', + last_active_at: '1731000000', + last_login_at: '1731000000', + created_at: '1731000000', + status: 'active', + }, + { + id: '2', + name: 'Admin User', + email: 'admin@example.com', + avatar: '', + avatar_url: '', + role: 'admin', + last_active_at: '1731000000', + last_login_at: '1731000000', + created_at: '1731000000', + status: 'active', + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useAppContext).mockReturnValue({ + userProfile: { email: 'owner@example.com' }, + currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: true, + isCurrentWorkspaceManager: true, + } as unknown as AppContextValue) + + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: mockAccounts }, + refetch: mockRefetch, + } as unknown as ReturnType) + + vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ + systemFeatures: { is_email_setup: true }, + } as unknown as Parameters[0])) + + vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ + enableBilling: false, + isAllowTransferWorkspace: true, + })) + + vi.mocked(useFormatTimeFromNow).mockReturnValue({ + formatTimeFromNow: mockFormatTimeFromNow, + }) + }) + + it('should render workspace and member information', () => { + render() + + expect(screen.getByText('Test Workspace')).toBeInTheDocument() + expect(screen.getByText('Owner User')).toBeInTheDocument() + expect(screen.getByText('Admin User')).toBeInTheDocument() + }) + + it('should open and close invite modal', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /invite/i })) + expect(screen.getByText('Invite Modal')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Close Invite Modal' })) + expect(screen.queryByText('Invite Modal')).not.toBeInTheDocument() + }) + + it('should open invited modal after invite results are sent', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /invite/i })) + await user.click(screen.getByRole('button', { name: 'Send Invite Results' })) + + expect(screen.getByText('Invited Modal')).toBeInTheDocument() + expect(mockRefetch).toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Close Invited Modal' })) + expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument() + }) + + it('should open transfer ownership modal when transfer action is used', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /transfer ownership/i })) + expect(screen.getByText('Transfer Ownership Modal')).toBeInTheDocument() + }) + + it('should show non-interactive owner role when transfer ownership is not allowed', () => { + vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ + enableBilling: false, + isAllowTransferWorkspace: false, + })) + + render() + + expect(screen.getByText('common.members.owner')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument() + }) + + it('should hide manager controls for non-owner non-manager users', () => { + vi.mocked(useAppContext).mockReturnValue({ + userProfile: { email: 'admin@example.com' }, + currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceManager: false, + } as unknown as AppContextValue) + + render() + + expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument() + expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/invite-button.spec.tsx new file mode 100644 index 0000000000..7388c7ef3b --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-button.spec.tsx @@ -0,0 +1,71 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWorkspacePermissions } from '@/service/use-workspace' +import InviteButton from './invite-button' + +vi.mock('@/context/app-context') +vi.mock('@/context/global-public-context') +vi.mock('@/service/use-workspace') + +describe('InviteButton', () => { + const setupMocks = ({ + brandingEnabled, + isFetching, + allowInvite, + }: { + brandingEnabled: boolean + isFetching: boolean + allowInvite?: boolean + }) => { + vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ + systemFeatures: { branding: { enabled: brandingEnabled } }, + } as unknown as Parameters[0])) + vi.mocked(useWorkspacePermissions).mockReturnValue({ + data: allowInvite === undefined ? null : { allow_member_invite: allowInvite }, + isFetching, + } as unknown as ReturnType) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace, + } as unknown as AppContextValue) + }) + + it('should show invite button when branding is disabled', () => { + setupMocks({ brandingEnabled: false, isFetching: false }) + + render() + + expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument() + }) + + it('should show loading status while permissions are loading', () => { + setupMocks({ brandingEnabled: true, isFetching: true }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should hide invite button when permission is denied', () => { + setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false }) + + render() + + expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument() + }) + + it('should show invite button when permission is granted', () => { + setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true }) + + render() + + expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx new file mode 100644 index 0000000000..fc733d9cd7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.spec.tsx @@ -0,0 +1,118 @@ +import type { InvitationResponse } from '@/models/common' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' +import { useProviderContextSelector } from '@/context/provider-context' +import { inviteMember } from '@/service/common' +import InviteModal from './index' + +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: vi.fn(), + useProviderContext: vi.fn(() => ({ + datasetOperatorEnabled: true, + })), +})) +vi.mock('@/service/common') +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +describe('InviteModal', () => { + const mockOnCancel = vi.fn() + const mockOnSend = vi.fn() + const mockRefreshLicenseLimit = vi.fn() + const mockNotify = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 5, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + }) + + const renderModal = (isEmailSetup = true) => render( + + + , + ) + + it('should render invite modal content', async () => { + renderModal() + + expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() + }) + + it('should show warning when email service is not configured', async () => { + renderModal(false) + + expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument() + }) + + it('should enable send button after entering an email', async () => { + const user = userEvent.setup() + + renderModal() + + const input = screen.getByRole('textbox') + await user.type(input, 'user@example.com{enter}') + + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled() + }) + + it('should not close modal when invite request fails', async () => { + const user = userEvent.setup() + vi.mocked(inviteMember).mockRejectedValue(new Error('request failed')) + + renderModal() + + await user.type(screen.getByRole('textbox'), 'user@example.com{enter}') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalled() + expect(mockOnCancel).not.toHaveBeenCalled() + expect(mockOnSend).not.toHaveBeenCalled() + }) + }) + + it('should send invites and close modal on successful submission', async () => { + const user = userEvent.setup() + vi.mocked(inviteMember).mockResolvedValue({ + result: 'success', + invitation_results: [], + } as InvitationResponse) + + renderModal() + + const input = screen.getByRole('textbox') + await user.type(input, 'user@example.com{enter}') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalled() + expect(mockRefreshLicenseLimit).toHaveBeenCalled() + expect(mockOnCancel).toHaveBeenCalled() + expect(mockOnSend).toHaveBeenCalled() + }) + }) + + it('should keep send button disabled when license limit is exceeded', async () => { + const user = userEvent.setup() + + vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({ + licenseLimit: { workspace_members: { size: 10, limit: 10 } }, + refreshLicenseLimit: mockRefreshLicenseLimit, + } as unknown as Parameters[0])) + + renderModal() + + const input = screen.getByRole('textbox') + await user.type(input, 'user@example.com{enter}') + + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx new file mode 100644 index 0000000000..3c7a496a74 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/role-selector.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { vi } from 'vitest' +import { useProviderContext } from '@/context/provider-context' +import RoleSelector from './role-selector' + +vi.mock('@/context/provider-context') + +type WrapperProps = { + initialRole?: 'normal' | 'editor' | 'admin' | 'dataset_operator' +} + +const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => { + const [role, setRole] = useState<'normal' | 'editor' | 'admin' | 'dataset_operator'>(initialRole) + return +} + +describe('RoleSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useProviderContext).mockReturnValue({ + datasetOperatorEnabled: true, + } as unknown as ReturnType) + }) + + it('should show current role in trigger text', () => { + render() + + expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument() + }) + + it.each([ + 'common.members.admin', + 'common.members.editor', + 'common.members.datasetOperator', + ])('should update selected role after user chooses %s', async (nextRoleLabel) => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.invitedAsRole/i)) + await user.click(screen.getByText(nextRoleLabel)) + + expect(screen.getByText(new RegExp(nextRoleLabel.replace('.', '\\.'), 'i'))).toBeInTheDocument() + }) + + it('should hide dataset operator option when feature is disabled', async () => { + const user = userEvent.setup() + + vi.mocked(useProviderContext).mockReturnValue({ + datasetOperatorEnabled: false, + } as unknown as ReturnType) + + render() + + await user.click(screen.getByText(/members\.invitedAsRole/i)) + + expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx new file mode 100644 index 0000000000..127c33a29f --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invited-modal/index.spec.tsx @@ -0,0 +1,24 @@ +import type { InvitationResult } from '@/models/common' +import { render, screen } from '@testing-library/react' +import InvitedModal from './index' + +vi.mock('@/config', () => ({ + IS_CE_EDITION: true, +})) + +describe('InvitedModal', () => { + const mockOnCancel = vi.fn() + const results: InvitationResult[] = [ + { email: 'success@example.com', status: 'success', url: 'http://invite.com/1' }, + { email: 'failed@example.com', status: 'failed', message: 'Error msg' }, + ] + + it('should show success and failed invitation sections', async () => { + render() + + expect(await screen.findByText(/members\.invitationSent$/i)).toBeInTheDocument() + expect(await screen.findByText(/members\.invitationLink/i)).toBeInTheDocument() + expect(screen.getByText('http://invite.com/1')).toBeInTheDocument() + expect(screen.getByText('failed@example.com')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx new file mode 100644 index 0000000000..4a2dbb54e7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invited-modal/invitation-link.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import InvitationLink from './invitation-link' + +describe('InvitationLink', () => { + const value = { email: 'test@example.com', status: 'success' as const, url: '/invite/123' } + + it('should render invitation url and keep it visible after click', async () => { + const user = userEvent.setup() + + render() + + const url = screen.getByText('/invite/123') + await user.click(url) + + expect(url).toBeInTheDocument() + }) + + it('should keep link visible after copy feedback timeout passes', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('/invite/123')) + + await waitFor(() => { + expect(screen.getByText('/invite/123')).toBeInTheDocument() + }, { timeout: 1500 }) + }) +}) diff --git a/web/app/components/header/account-setting/members-page/operation/index.spec.tsx b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx index fbe3959a0f..661b2fbc83 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx @@ -1,5 +1,6 @@ import type { Member } from '@/models/common' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { vi } from 'vitest' import { ToastContext } from '@/app/components/base/toast' import Operation from './index' @@ -55,20 +56,45 @@ describe('Operation', () => { }) it('shows dataset operator option when the feature flag is enabled', async () => { + const user = userEvent.setup() + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) renderOperation() - fireEvent.click(screen.getByText('common.members.editor')) + await user.click(screen.getByText('common.members.editor')) expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() }) + it('shows owner-allowed role options for admin operators', async () => { + const user = userEvent.setup() + + renderOperation({}, 'admin') + + await user.click(screen.getByText('common.members.editor')) + + expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument() + expect(screen.getByText('common.members.normal')).toBeInTheDocument() + }) + + it('does not show role options for unsupported operators', async () => { + const user = userEvent.setup() + + renderOperation({}, 'normal') + + await user.click(screen.getByText('common.members.editor')) + + expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument() + expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument() + }) + it('calls updateMemberRole and onOperate when selecting another role', async () => { + const user = userEvent.setup() const onOperate = vi.fn() renderOperation({}, 'owner', onOperate) - fireEvent.click(screen.getByText('common.members.editor')) - fireEvent.click(await screen.findByText('common.members.normal')) + await user.click(screen.getByText('common.members.editor')) + await user.click(await screen.findByText('common.members.normal')) await waitFor(() => { expect(mockUpdateMemberRole).toHaveBeenCalled() @@ -77,11 +103,12 @@ describe('Operation', () => { }) it('calls deleteMemberOrCancelInvitation when removing the member', async () => { + const user = userEvent.setup() const onOperate = vi.fn() renderOperation({}, 'owner', onOperate) - fireEvent.click(screen.getByText('common.members.editor')) - fireEvent.click(await screen.findByText('common.members.removeFromTeam')) + await user.click(screen.getByText('common.members.editor')) + await user.click(await screen.findByText('common.members.removeFromTeam')) await waitFor(() => { expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx new file mode 100644 index 0000000000..74f86d601d --- /dev/null +++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.spec.tsx @@ -0,0 +1,89 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWorkspacePermissions } from '@/service/use-workspace' +import TransferOwnership from './transfer-ownership' + +vi.mock('@/context/app-context') +vi.mock('@/context/global-public-context') +vi.mock('@/service/use-workspace') + +describe('TransferOwnership', () => { + const setupMocks = ({ + brandingEnabled, + isFetching, + allowOwnerTransfer, + }: { + brandingEnabled: boolean + isFetching: boolean + allowOwnerTransfer?: boolean + }) => { + vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ + systemFeatures: { branding: { enabled: brandingEnabled } }, + } as unknown as Parameters[0])) + vi.mocked(useWorkspacePermissions).mockReturnValue({ + data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer }, + isFetching, + } as unknown as ReturnType) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace, + } as unknown as AppContextValue) + }) + + it('should show loading status while permissions are loading', () => { + setupMocks({ brandingEnabled: true, isFetching: true }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should show owner text without transfer menu when transfer is forbidden', () => { + setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false }) + + render() + + expect(screen.getByText(/members\.owner/i)).toBeInTheDocument() + expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull() + }) + + it('should open transfer dialog when transfer option is selected', async () => { + const user = userEvent.setup() + const onOperate = vi.fn() + + setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true }) + + render() + + await user.click(screen.getByRole('button', { name: /members\.owner/i })) + const transferOptionText = await screen.findByText(/members\.transferOwnership/i) + const transferOption = transferOptionText.closest('div.cursor-pointer') + if (!transferOption) + throw new Error('Transfer option container not found') + fireEvent.click(transferOption) + + await waitFor(() => { + expect(onOperate).toHaveBeenCalledTimes(1) + }) + }) + + it('should allow transfer menu when branding is disabled', async () => { + const user = userEvent.setup() + + setupMocks({ brandingEnabled: false, isFetching: false }) + + render() + + await user.click(screen.getByRole('button', { name: /members\.owner/i })) + + expect(screen.getByText(/members\.transferOwnership/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx new file mode 100644 index 0000000000..d2ef1a6af7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.spec.tsx @@ -0,0 +1,149 @@ +import type { AppContextValue } from '@/context/app-context' +import type { ICurrentWorkspace } from '@/models/common' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' +import { useAppContext } from '@/context/app-context' +import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' +import TransferOwnershipModal from './index' + +vi.mock('@/context/app-context') +vi.mock('@/service/common') + +vi.mock('./member-selector', () => ({ + default: ({ onSelect }: { onSelect: (id: string) => void }) => ( + + ), +})) + +describe('TransferOwnershipModal', () => { + const mockOnClose = vi.fn() + const mockNotify = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType) + vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {}) + + vi.mocked(useAppContext).mockReturnValue({ + currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + userProfile: { email: 'owner@example.com', id: 'owner-id' }, + } as unknown as AppContextValue) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + const renderModal = () => render( + + + , + ) + + const mockEmailVerification = ({ + isValid = true, + token = 'final-token', + }: { + isValid?: boolean + token?: string + } = {}) => { + vi.mocked(sendOwnerEmail).mockResolvedValue({ + data: 'step-token', + result: 'success', + } as Awaited>) + vi.mocked(verifyOwnerEmail).mockResolvedValue({ + is_valid: isValid, + token, + result: 'success', + } as Awaited>) + } + + const goToTransferStep = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + await user.type(screen.getByPlaceholderText(/members\.transferModal\.codePlaceholder/i), '123456') + await user.click(screen.getByRole('button', { name: /members\.transferModal\.continue/i })) + } + + const selectNewOwnerAndSubmit = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: /select member/i })) + await user.click(screen.getByRole('button', { name: /members\.transferModal\.transfer$/i })) + } + + it('should complete ownership transfer flow through all steps', async () => { + const user = userEvent.setup() + + mockEmailVerification() + vi.mocked(ownershipTransfer).mockResolvedValue({ + result: 'success', + } as Awaited>) + + const mockReload = vi.fn() + vi.stubGlobal('location', { ...window.location, reload: mockReload }) + + renderModal() + + await goToTransferStep(user) + + expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument() + + await selectNewOwnerAndSubmit(user) + + await waitFor(() => { + expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' }) + expect(mockReload).toHaveBeenCalled() + }) + }, 15000) + + it('should show error when email verification returns invalid code', async () => { + const user = userEvent.setup() + + mockEmailVerification({ isValid: false, token: 'step-token' }) + + renderModal() + + await goToTransferStep(user) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + it('should show error when sending verification email fails', async () => { + const user = userEvent.setup() + + vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error')) + + renderModal() + + await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i })) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) + + it('should show error when ownership transfer fails', async () => { + const user = userEvent.setup() + + mockEmailVerification() + vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed')) + + renderModal() + + await goToTransferStep(user) + await selectNewOwnerAndSubmit(user) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx new file mode 100644 index 0000000000..afed247394 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.spec.tsx @@ -0,0 +1,107 @@ +import type { Member } from '@/models/common' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { vi } from 'vitest' +import { useMembers } from '@/service/use-common' +import MemberSelector from './member-selector' + +vi.mock('@/service/use-common') + +const MemberSelectorHarness = ({ initialValue = '', exclude = [] as string[] }: { initialValue?: string, exclude?: string[] }) => { + const [selected, setSelected] = useState(initialValue) + return ( + <> + + {selected && ( +
+ Selected: + {' '} + {selected} +
+ )} + + ) +} + +describe('MemberSelector', () => { + const mockMembers = [ + { id: '1', name: 'User 1', email: 'user1@example.com', role: 'admin' }, + { id: '2', name: 'User 2', email: 'user2@example.com', role: 'normal' }, + ] as Member[] + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useMembers).mockReturnValue({ + data: { accounts: mockMembers }, + } as unknown as ReturnType) + }) + + it('should show member options when selector is opened', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + + expect(screen.getByPlaceholderText(/common\.operation\.search/i)).toBeInTheDocument() + expect(screen.getByText('User 1')).toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + + it('should filter displayed members by search term', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + await user.type(screen.getByPlaceholderText(/common\.operation\.search/i), 'User 2') + + expect(screen.queryByText('User 1')).not.toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + + it('should show selected member after clicking an option', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + await user.click(screen.getByText('User 1')) + + expect(screen.getByText('Selected: 1')).toBeInTheDocument() + }) + + it('should show selected value details when an initial value is provided', () => { + render() + + expect(screen.getByText('User 2')).toBeInTheDocument() + expect(screen.getByText('user2@example.com')).toBeInTheDocument() + }) + + it('should hide excluded members from options', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + + expect(screen.queryByText('User 1')).not.toBeInTheDocument() + expect(screen.getByText('User 2')).toBeInTheDocument() + }) + + it('should render empty options when member data is unavailable', async () => { + const user = userEvent.setup() + + vi.mocked(useMembers).mockReturnValue({ + data: undefined, + } as unknown as ReturnType) + + render() + + await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i)) + + expect(screen.queryByText('User 1')).not.toBeInTheDocument() + expect(screen.queryByText('User 2')).not.toBeInTheDocument() + }) +})