test(web): add members-page account-setting specs and improve coverage (#32311)

This commit is contained in:
akashseth-ifp
2026-02-23 17:36:35 +05:30
committed by GitHub
parent 4c48e3b997
commit 42af9d5438
12 changed files with 982 additions and 9 deletions

View File

@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
it('should render year options', () => { it('should render year options', () => {
const props = createOptionsProps() const props = createOptionsProps()
render(<Options {...props} />) const { container } = render(<Options {...props} />)
const allItems = screen.getAllByRole('listitem') const yearList = container.querySelectorAll('ul')[1]
expect(allItems).toHaveLength(212) expect(yearList?.children).toHaveLength(200)
}) })
}) })

View File

@@ -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(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<EditWorkspaceModal onCancel={mockOnCancel} />
</ToastContext.Provider>,
)
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()
})
})

View File

@@ -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 }) => (
<div>
<div>Edit Workspace Modal</div>
<button onClick={onCancel}>Close Edit Workspace</button>
</div>
),
}))
vi.mock('./invite-button', () => ({
default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => (
<button onClick={onClick} disabled={disabled}>Invite</button>
),
}))
vi.mock('./invite-modal', () => ({
default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => (
<div>
<div>Invite Modal</div>
<button onClick={onCancel}>Close Invite Modal</button>
<button onClick={() => onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])}>Send Invite Results</button>
</div>
),
}))
vi.mock('./invited-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>Invited Modal</div>
<button onClick={onCancel}>Close Invited Modal</button>
</div>
),
}))
vi.mock('./operation', () => ({
default: () => <div>Member Operation</div>,
}))
vi.mock('./operation/transfer-ownership', () => ({
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
}))
vi.mock('./transfer-ownership-modal', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div>
<div>Transfer Ownership Modal</div>
<button onClick={onClose}>Close Transfer Modal</button>
</div>
),
}))
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<typeof useMembers>)
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { is_email_setup: true },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
isAllowTransferWorkspace: true,
}))
vi.mocked(useFormatTimeFromNow).mockReturnValue({
formatTimeFromNow: mockFormatTimeFromNow,
})
})
it('should render workspace and member information', () => {
render(<MembersPage />)
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(<MembersPage />)
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(<MembersPage />)
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(<MembersPage />)
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(<MembersPage />)
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(<MembersPage />)
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
})
})

View File

@@ -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<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
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(<InviteButton />)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true })
render(<InviteButton />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should hide invite button when permission is denied', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false })
render(<InviteButton />)
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(<InviteButton />)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
})

View File

@@ -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<typeof selector>[0]))
})
const renderModal = (isEmailSetup = true) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
</ToastContext.Provider>,
)
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<typeof selector>[0]))
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
})

View File

@@ -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 <RoleSelector value={role} onChange={setRole} />
}
describe('RoleSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
datasetOperatorEnabled: true,
} as unknown as ReturnType<typeof useProviderContext>)
})
it('should show current role in trigger text', () => {
render(<RoleSelectorWrapper initialRole="admin" />)
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(<RoleSelectorWrapper initialRole="normal" />)
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<typeof useProviderContext>)
render(<RoleSelectorWrapper />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
})
})

View File

@@ -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(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
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()
})
})

View File

@@ -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(<InvitationLink value={value} />)
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(<InvitationLink value={value} />)
await user.click(screen.getByText('/invite/123'))
await waitFor(() => {
expect(screen.getByText('/invite/123')).toBeInTheDocument()
}, { timeout: 1500 })
})
})

View File

@@ -1,5 +1,6 @@
import type { Member } from '@/models/common' 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 { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import Operation from './index' import Operation from './index'
@@ -55,20 +56,45 @@ describe('Operation', () => {
}) })
it('shows dataset operator option when the feature flag is enabled', async () => { it('shows dataset operator option when the feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation() renderOperation()
fireEvent.click(screen.getByText('common.members.editor')) await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() 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 () => { it('calls updateMemberRole and onOperate when selecting another role', async () => {
const user = userEvent.setup()
const onOperate = vi.fn() const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate) renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor')) await user.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.normal')) await user.click(await screen.findByText('common.members.normal'))
await waitFor(() => { await waitFor(() => {
expect(mockUpdateMemberRole).toHaveBeenCalled() expect(mockUpdateMemberRole).toHaveBeenCalled()
@@ -77,11 +103,12 @@ describe('Operation', () => {
}) })
it('calls deleteMemberOrCancelInvitation when removing the member', async () => { it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const user = userEvent.setup()
const onOperate = vi.fn() const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate) renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor')) await user.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.removeFromTeam')) await user.click(await screen.findByText('common.members.removeFromTeam'))
await waitFor(() => { await waitFor(() => {
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled() expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()

View File

@@ -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<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
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(<TransferOwnership onOperate={vi.fn()} />)
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(<TransferOwnership onOperate={vi.fn()} />)
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(<TransferOwnership onOperate={onOperate} />)
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(<TransferOwnership onOperate={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
expect(screen.getByText(/members\.transferOwnership/i)).toBeInTheDocument()
})
})

View File

@@ -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 }) => (
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
),
}))
describe('TransferOwnershipModal', () => {
const mockOnClose = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType<typeof setInterval>)
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(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<TransferOwnershipModal show onClose={mockOnClose} />
</ToastContext.Provider>,
)
const mockEmailVerification = ({
isValid = true,
token = 'final-token',
}: {
isValid?: boolean
token?: string
} = {}) => {
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: 'step-token',
result: 'success',
} as Awaited<ReturnType<typeof sendOwnerEmail>>)
vi.mocked(verifyOwnerEmail).mockResolvedValue({
is_valid: isValid,
token,
result: 'success',
} as Awaited<ReturnType<typeof verifyOwnerEmail>>)
}
const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => {
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<typeof userEvent.setup>) => {
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<ReturnType<typeof ownershipTransfer>>)
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',
}))
})
})
})

View File

@@ -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<string>(initialValue)
return (
<>
<MemberSelector value={selected} onSelect={setSelected} exclude={exclude} />
{selected && (
<div>
Selected:
{' '}
{selected}
</div>
)}
</>
)
}
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<typeof useMembers>)
})
it('should show member options when selector is opened', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
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(<MemberSelectorHarness />)
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(<MemberSelectorHarness />)
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(<MemberSelectorHarness initialValue="2" />)
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(<MemberSelectorHarness exclude={['1']} />)
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<typeof useMembers>)
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.queryByText('User 2')).not.toBeInTheDocument()
})
})