InviteMemberButton.test.tsx 8.1 KB

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