mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 01:45:13 +00:00
test(web): add members-page account-setting specs and improve coverage (#32311)
This commit is contained in:
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
const { container } = render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
const yearList = container.querySelectorAll('ul')[1]
|
||||
expect(yearList?.children).toHaveLength(200)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user