InviteMemberButton.test.tsx254 lines · main
| 1 | import { fireEvent, screen, waitFor } from '@testing-library/react' |
| 2 | import userEvent from '@testing-library/user-event' |
| 3 | import { toast } from 'sonner' |
| 4 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 5 | |
| 6 | import { InviteMemberButton } from '@/components/interfaces/Organization/TeamSettings/InviteMemberButton' |
| 7 | import { customRender } from '@/tests/lib/custom-render' |
| 8 | |
| 9 | vi.mock('sonner', () => ({ |
| 10 | toast: { success: vi.fn(), error: vi.fn() }, |
| 11 | })) |
| 12 | |
| 13 | vi.mock('common', async (importOriginal) => { |
| 14 | const actual = (await importOriginal()) as typeof import('common') |
| 15 | return { ...actual, useParams: () => ({ slug: 'test-org' }) } |
| 16 | }) |
| 17 | |
| 18 | vi.mock('@/lib/profile', () => ({ |
| 19 | useProfile: () => ({ profile: { id: 1, gotrue_id: 'user-1' } }), |
| 20 | })) |
| 21 | |
| 22 | vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ |
| 23 | useSelectedOrganizationQuery: () => ({ |
| 24 | data: { id: 1, slug: 'test-org', name: 'Test Org' }, |
| 25 | }), |
| 26 | })) |
| 27 | |
| 28 | vi.mock('@/hooks/misc/useCheckPermissions', () => ({ |
| 29 | useGetPermissions: () => ({ permissions: [], organizationSlug: 'test-org' }), |
| 30 | doPermissionsCheck: () => true, |
| 31 | useAsyncCheckPermissions: () => ({ can: true, isSuccess: true }), |
| 32 | })) |
| 33 | |
| 34 | vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ |
| 35 | useIsFeatureEnabled: () => ({ organizationMembersCreate: true }), |
| 36 | })) |
| 37 | |
| 38 | vi.mock('@/data/organizations/organization-members-query', () => ({ |
| 39 | useOrganizationMembersQuery: () => ({ |
| 40 | data: [ |
| 41 | { |
| 42 | gotrue_id: 'user-1', |
| 43 | primary_email: 'me@example.com', |
| 44 | role_ids: [1], |
| 45 | }, |
| 46 | { |
| 47 | gotrue_id: 'existing-user', |
| 48 | primary_email: 'existing@example.com', |
| 49 | role_ids: [1], |
| 50 | }, |
| 51 | ], |
| 52 | }), |
| 53 | })) |
| 54 | |
| 55 | const mockRoles = { |
| 56 | org_scoped_roles: [{ id: 1, name: 'Developer', description: null }], |
| 57 | } |
| 58 | vi.mock('@/data/organization-members/organization-roles-query', () => ({ |
| 59 | useOrganizationRolesV2Query: () => ({ data: mockRoles, isSuccess: true }), |
| 60 | })) |
| 61 | |
| 62 | vi.mock('@/data/sso/sso-config-query', () => ({ |
| 63 | useOrgSSOConfigQuery: () => ({ data: null }), |
| 64 | })) |
| 65 | |
| 66 | vi.mock('@/data/subscriptions/org-subscription-query', () => ({ |
| 67 | useHasAccessToProjectLevelPermissions: () => false, |
| 68 | })) |
| 69 | |
| 70 | vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ |
| 71 | useCheckEntitlements: () => ({ hasAccess: false }), |
| 72 | })) |
| 73 | |
| 74 | vi.mock('@/components/interfaces/Organization/TeamSettings/TeamSettings.utils', () => ({ |
| 75 | useGetRolesManagementPermissions: () => ({ rolesAddable: [1], rolesRemovable: [1] }), |
| 76 | })) |
| 77 | |
| 78 | const mockInvite = vi.fn().mockResolvedValue({ succeeded: [], failed: [] }) |
| 79 | vi.mock('@/data/organization-members/organization-invitation-create-mutation', () => ({ |
| 80 | useOrganizationCreateInvitationMutation: () => ({ |
| 81 | mutateAsync: mockInvite, |
| 82 | isPending: false, |
| 83 | }), |
| 84 | })) |
| 85 | |
| 86 | vi.mock('@/hooks/ui/useConfirmOnClose', () => ({ |
| 87 | useConfirmOnClose: ({ onClose }: { checkIsDirty: () => boolean; onClose: () => void }) => ({ |
| 88 | confirmOnClose: onClose, |
| 89 | handleOpenChange: (open: boolean) => { |
| 90 | if (!open) onClose() |
| 91 | }, |
| 92 | modalProps: { visible: false, onClose, onCancel: vi.fn() }, |
| 93 | }), |
| 94 | })) |
| 95 | |
| 96 | // Helpers |
| 97 | async function openDialog() { |
| 98 | await userEvent.click(screen.getByRole('button', { name: /invite members/i })) |
| 99 | return screen.findByRole('dialog') |
| 100 | } |
| 101 | |
| 102 | async function submitForm(emailValue: string) { |
| 103 | await openDialog() |
| 104 | fireEvent.change(screen.getByPlaceholderText(/name@example\.com/i), { |
| 105 | target: { value: emailValue }, |
| 106 | }) |
| 107 | fireEvent.click(screen.getByRole('button', { name: /send invitation/i })) |
| 108 | } |
| 109 | |
| 110 | // Tests |
| 111 | describe('InviteMemberButton', () => { |
| 112 | beforeEach(() => { |
| 113 | vi.clearAllMocks() |
| 114 | mockInvite.mockResolvedValue({ succeeded: [], failed: [] }) |
| 115 | }) |
| 116 | |
| 117 | it('renders an enabled Invite members button', () => { |
| 118 | customRender(<InviteMemberButton />) |
| 119 | expect(screen.getByRole('button', { name: /invite members/i })).toBeEnabled() |
| 120 | }) |
| 121 | |
| 122 | it('opens the invite dialog when the button is clicked', async () => { |
| 123 | customRender(<InviteMemberButton />) |
| 124 | await openDialog() |
| 125 | expect(screen.getByRole('dialog')).toBeInTheDocument() |
| 126 | expect(screen.getByText('Invite team members')).toBeInTheDocument() |
| 127 | }) |
| 128 | |
| 129 | it('calls the mutation with a single email in an array', async () => { |
| 130 | customRender(<InviteMemberButton />) |
| 131 | await submitForm('new@example.com') |
| 132 | |
| 133 | await waitFor(() => { |
| 134 | expect(mockInvite).toHaveBeenCalledWith( |
| 135 | expect.objectContaining({ emails: ['new@example.com'] }) |
| 136 | ) |
| 137 | }) |
| 138 | }) |
| 139 | |
| 140 | it('calls the mutation with multiple emails parsed from a comma-separated input', async () => { |
| 141 | customRender(<InviteMemberButton />) |
| 142 | await submitForm('alice@example.com, bob@example.com, carol@example.com') |
| 143 | |
| 144 | await waitFor(() => { |
| 145 | expect(mockInvite).toHaveBeenCalledWith( |
| 146 | expect.objectContaining({ |
| 147 | emails: ['alice@example.com', 'bob@example.com', 'carol@example.com'], |
| 148 | }) |
| 149 | ) |
| 150 | }) |
| 151 | }) |
| 152 | |
| 153 | it('lowercases emails before sending', async () => { |
| 154 | customRender(<InviteMemberButton />) |
| 155 | await submitForm('User@Example.COM') |
| 156 | |
| 157 | await waitFor(() => { |
| 158 | expect(mockInvite).toHaveBeenCalledWith( |
| 159 | expect.objectContaining({ emails: ['user@example.com'] }) |
| 160 | ) |
| 161 | }) |
| 162 | }) |
| 163 | |
| 164 | it('shows a validation error for an invalid email', async () => { |
| 165 | customRender(<InviteMemberButton />) |
| 166 | await openDialog() |
| 167 | fireEvent.change(screen.getByPlaceholderText(/name@example\.com/i), { |
| 168 | target: { value: 'not-an-email' }, |
| 169 | }) |
| 170 | fireEvent.click(screen.getByRole('button', { name: /send invitation/i })) |
| 171 | |
| 172 | expect(await screen.findByText(/invalid email address: "not-an-email"/i)).toBeInTheDocument() |
| 173 | expect(mockInvite).not.toHaveBeenCalled() |
| 174 | }) |
| 175 | |
| 176 | it('shows an error toast and skips the mutation for an already-existing member', async () => { |
| 177 | customRender(<InviteMemberButton />) |
| 178 | await submitForm('existing@example.com') |
| 179 | |
| 180 | await waitFor(() => { |
| 181 | expect(toast.error).toHaveBeenCalledWith( |
| 182 | 'existing@example.com is already in this organization' |
| 183 | ) |
| 184 | }) |
| 185 | expect(mockInvite).not.toHaveBeenCalled() |
| 186 | }) |
| 187 | |
| 188 | it('still invites new emails in a batch that also contains an existing member', async () => { |
| 189 | customRender(<InviteMemberButton />) |
| 190 | await submitForm('new@example.com, existing@example.com') |
| 191 | |
| 192 | await waitFor(() => { |
| 193 | expect(toast.error).toHaveBeenCalled() |
| 194 | expect(mockInvite).toHaveBeenCalledWith( |
| 195 | expect.objectContaining({ emails: ['new@example.com'] }) |
| 196 | ) |
| 197 | }) |
| 198 | }) |
| 199 | |
| 200 | it('shows a success toast for a single email in succeeded', async () => { |
| 201 | mockInvite.mockResolvedValueOnce({ succeeded: ['new@example.com'], failed: [] }) |
| 202 | customRender(<InviteMemberButton />) |
| 203 | await submitForm('new@example.com') |
| 204 | |
| 205 | await waitFor(() => { |
| 206 | expect(toast.success).toHaveBeenCalledWith('Successfully sent invitation to new member') |
| 207 | }) |
| 208 | }) |
| 209 | |
| 210 | it('shows a plural success toast when multiple emails succeeded', async () => { |
| 211 | mockInvite.mockResolvedValueOnce({ |
| 212 | succeeded: ['alice@example.com', 'bob@example.com'], |
| 213 | failed: [], |
| 214 | }) |
| 215 | customRender(<InviteMemberButton />) |
| 216 | await submitForm('alice@example.com, bob@example.com') |
| 217 | |
| 218 | await waitFor(() => { |
| 219 | expect(toast.success).toHaveBeenCalledWith('Successfully sent invitations to 2 new members') |
| 220 | }) |
| 221 | }) |
| 222 | |
| 223 | it('shows an error toast with the server error for each failed email', async () => { |
| 224 | mockInvite.mockResolvedValueOnce({ |
| 225 | succeeded: [], |
| 226 | failed: [{ email: 'new@example.com', error: 'Domain not allowed' }], |
| 227 | }) |
| 228 | customRender(<InviteMemberButton />) |
| 229 | await submitForm('new@example.com') |
| 230 | |
| 231 | await waitFor(() => { |
| 232 | expect(toast.error).toHaveBeenCalledWith( |
| 233 | 'Failed to invite new@example.com: Domain not allowed' |
| 234 | ) |
| 235 | }) |
| 236 | expect(toast.success).not.toHaveBeenCalled() |
| 237 | }) |
| 238 | |
| 239 | it('shows both success and error toasts for a partial batch result', async () => { |
| 240 | mockInvite.mockResolvedValueOnce({ |
| 241 | succeeded: ['alice@example.com'], |
| 242 | failed: [{ email: 'bob@example.com', error: 'Domain not allowed' }], |
| 243 | }) |
| 244 | customRender(<InviteMemberButton />) |
| 245 | await submitForm('alice@example.com, bob@example.com') |
| 246 | |
| 247 | await waitFor(() => { |
| 248 | expect(toast.success).toHaveBeenCalledWith('Successfully sent invitation to new member') |
| 249 | expect(toast.error).toHaveBeenCalledWith( |
| 250 | 'Failed to invite bob@example.com: Domain not allowed' |
| 251 | ) |
| 252 | }) |
| 253 | }) |
| 254 | }) |