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()
+ })
+})