InviteMemberButton.test.tsx254 lines · main
1import { fireEvent, screen, waitFor } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3import { toast } from 'sonner'
4import { beforeEach, describe, expect, it, vi } from 'vitest'
5
6import { InviteMemberButton } from '@/components/interfaces/Organization/TeamSettings/InviteMemberButton'
7import { customRender } from '@/tests/lib/custom-render'
8
9vi.mock('sonner', () => ({
10 toast: { success: vi.fn(), error: vi.fn() },
11}))
12
13vi.mock('common', async (importOriginal) => {
14 const actual = (await importOriginal()) as typeof import('common')
15 return { ...actual, useParams: () => ({ slug: 'test-org' }) }
16})
17
18vi.mock('@/lib/profile', () => ({
19 useProfile: () => ({ profile: { id: 1, gotrue_id: 'user-1' } }),
20}))
21
22vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
23 useSelectedOrganizationQuery: () => ({
24 data: { id: 1, slug: 'test-org', name: 'Test Org' },
25 }),
26}))
27
28vi.mock('@/hooks/misc/useCheckPermissions', () => ({
29 useGetPermissions: () => ({ permissions: [], organizationSlug: 'test-org' }),
30 doPermissionsCheck: () => true,
31 useAsyncCheckPermissions: () => ({ can: true, isSuccess: true }),
32}))
33
34vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
35 useIsFeatureEnabled: () => ({ organizationMembersCreate: true }),
36}))
37
38vi.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
55const mockRoles = {
56 org_scoped_roles: [{ id: 1, name: 'Developer', description: null }],
57}
58vi.mock('@/data/organization-members/organization-roles-query', () => ({
59 useOrganizationRolesV2Query: () => ({ data: mockRoles, isSuccess: true }),
60}))
61
62vi.mock('@/data/sso/sso-config-query', () => ({
63 useOrgSSOConfigQuery: () => ({ data: null }),
64}))
65
66vi.mock('@/data/subscriptions/org-subscription-query', () => ({
67 useHasAccessToProjectLevelPermissions: () => false,
68}))
69
70vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
71 useCheckEntitlements: () => ({ hasAccess: false }),
72}))
73
74vi.mock('@/components/interfaces/Organization/TeamSettings/TeamSettings.utils', () => ({
75 useGetRolesManagementPermissions: () => ({ rolesAddable: [1], rolesRemovable: [1] }),
76}))
77
78const mockInvite = vi.fn().mockResolvedValue({ succeeded: [], failed: [] })
79vi.mock('@/data/organization-members/organization-invitation-create-mutation', () => ({
80 useOrganizationCreateInvitationMutation: () => ({
81 mutateAsync: mockInvite,
82 isPending: false,
83 }),
84}))
85
86vi.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
97async function openDialog() {
98 await userEvent.click(screen.getByRole('button', { name: /invite members/i }))
99 return screen.findByRole('dialog')
100}
101
102async 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
111describe('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})