InviteMemberButton.utils.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { describe, expect, test } from 'vitest'
  2. import {
  3. buildProjectPayload,
  4. buildSsoPayload,
  5. categorizeInviteEmails,
  6. emailSchema,
  7. MAX_BATCH_INVITE_SIZE,
  8. parseEmails,
  9. } from '@/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils'
  10. import type { OrganizationMember } from '@/data/organizations/organization-members-query'
  11. describe('parseEmails', () => {
  12. test('parses a single email', () => {
  13. expect(parseEmails('user@example.com')).toStrictEqual(['user@example.com'])
  14. })
  15. test('parses multiple comma-separated emails', () => {
  16. expect(parseEmails('a@example.com,b@example.com,c@example.com')).toStrictEqual([
  17. 'a@example.com',
  18. 'b@example.com',
  19. 'c@example.com',
  20. ])
  21. })
  22. test('trims whitespace around each email', () => {
  23. expect(parseEmails(' a@example.com , b@example.com ')).toStrictEqual([
  24. 'a@example.com',
  25. 'b@example.com',
  26. ])
  27. })
  28. test('filters out empty entries from trailing, leading, or double commas', () => {
  29. expect(parseEmails(',a@example.com,,b@example.com,')).toStrictEqual([
  30. 'a@example.com',
  31. 'b@example.com',
  32. ])
  33. })
  34. test('removes duplicate email addresses', () => {
  35. expect(parseEmails('a@example.com,a@example.com')).toStrictEqual(['a@example.com'])
  36. })
  37. test('returns an empty array for an empty string', () => {
  38. expect(parseEmails('')).toStrictEqual([])
  39. })
  40. test('returns an empty array for a whitespace-only string', () => {
  41. expect(parseEmails(' ')).toStrictEqual([])
  42. })
  43. test('returns an empty array for commas only', () => {
  44. expect(parseEmails(',,,,')).toStrictEqual([])
  45. })
  46. test('parses space-separated emails', () => {
  47. expect(parseEmails('a@example.com b@example.com')).toStrictEqual([
  48. 'a@example.com',
  49. 'b@example.com',
  50. ])
  51. })
  52. test('parses line breaks and mixed comma or space separators', () => {
  53. expect(parseEmails('a@example.com\nb@example.com, c@example.com')).toStrictEqual([
  54. 'a@example.com',
  55. 'b@example.com',
  56. 'c@example.com',
  57. ])
  58. })
  59. })
  60. function makeMember(overrides: Partial<OrganizationMember> = {}): OrganizationMember {
  61. return {
  62. gotrue_id: 'gotrue-1',
  63. primary_email: 'member@example.com',
  64. role_ids: [1],
  65. username: 'member',
  66. ...overrides,
  67. } as OrganizationMember
  68. }
  69. describe('categorizeInviteEmails', () => {
  70. test('places a new email in toInvite when no members exist', () => {
  71. expect(categorizeInviteEmails(['new@example.com'], [])).toStrictEqual({
  72. alreadyInvited: [],
  73. alreadyMembers: [],
  74. toInvite: ['new@example.com'],
  75. })
  76. })
  77. test('places an email in alreadyMembers when that member exists without an invited_id', () => {
  78. const members = [makeMember({ primary_email: 'existing@example.com' })]
  79. expect(categorizeInviteEmails(['existing@example.com'], members)).toStrictEqual({
  80. alreadyInvited: [],
  81. alreadyMembers: ['existing@example.com'],
  82. toInvite: [],
  83. })
  84. })
  85. test('places an email in alreadyInvited when that member has an invited_id', () => {
  86. const members = [makeMember({ primary_email: 'invited@example.com', invited_id: 42 })]
  87. expect(categorizeInviteEmails(['invited@example.com'], members)).toStrictEqual({
  88. alreadyInvited: ['invited@example.com'],
  89. alreadyMembers: [],
  90. toInvite: [],
  91. })
  92. })
  93. test('correctly categorizes a mixed batch', () => {
  94. const members = [
  95. makeMember({ primary_email: 'member@example.com' }),
  96. makeMember({ primary_email: 'invited@example.com', invited_id: 7 }),
  97. ]
  98. expect(
  99. categorizeInviteEmails(
  100. ['new@example.com', 'member@example.com', 'invited@example.com'],
  101. members
  102. )
  103. ).toStrictEqual({
  104. alreadyInvited: ['invited@example.com'],
  105. alreadyMembers: ['member@example.com'],
  106. toInvite: ['new@example.com'],
  107. })
  108. })
  109. test('places all emails in toInvite when none match existing members', () => {
  110. const members = [makeMember({ primary_email: 'other@example.com' })]
  111. expect(
  112. categorizeInviteEmails(['a@example.com', 'b@example.com', 'c@example.com'], members)
  113. ).toStrictEqual({
  114. alreadyInvited: [],
  115. alreadyMembers: [],
  116. toInvite: ['a@example.com', 'b@example.com', 'c@example.com'],
  117. })
  118. })
  119. test('returns all-empty for an empty email list', () => {
  120. expect(categorizeInviteEmails([], [makeMember()])).toStrictEqual({
  121. alreadyInvited: [],
  122. alreadyMembers: [],
  123. toInvite: [],
  124. })
  125. })
  126. test('uses strict equality — does not match different casing', () => {
  127. // The component lowercases emails before calling this function,
  128. // so 'member@example.com' must NOT match 'Member@Example.com'
  129. const members = [makeMember({ primary_email: 'Member@Example.com' })]
  130. const result = categorizeInviteEmails(['member@example.com'], members)
  131. expect(result.toInvite).toStrictEqual(['member@example.com'])
  132. expect(result.alreadyMembers).toStrictEqual([])
  133. })
  134. })
  135. describe('buildProjectPayload', () => {
  136. test('returns empty object when applyToOrg is true', () => {
  137. expect(buildProjectPayload(true, 'ref_abc')).toStrictEqual({})
  138. })
  139. test('throws an error when applyToOrg is false but projectRef is empty', () => {
  140. expect(() => buildProjectPayload(false, '')).toThrowError(
  141. 'projectRef is required when applyToOrg is false'
  142. )
  143. })
  144. test('returns projects array when applyToOrg is false and projectRef is set', () => {
  145. expect(buildProjectPayload(false, 'ref_abc')).toStrictEqual({ projects: ['ref_abc'] })
  146. })
  147. test('wraps the projectRef in a single-element array', () => {
  148. expect(buildProjectPayload(false, 'my-project-ref')).toStrictEqual({
  149. projects: ['my-project-ref'],
  150. })
  151. })
  152. })
  153. describe('buildSsoPayload', () => {
  154. test('returns empty object for "auto"', () => {
  155. expect(buildSsoPayload('auto')).toStrictEqual({})
  156. })
  157. test('returns { requireSso: true } for "sso"', () => {
  158. expect(buildSsoPayload('sso')).toStrictEqual({ requireSso: true })
  159. })
  160. test('returns { requireSso: false } for "non-sso"', () => {
  161. expect(buildSsoPayload('non-sso')).toStrictEqual({ requireSso: false })
  162. })
  163. })
  164. function makeEmailList(count: number): string {
  165. return Array.from({ length: count }, (_, i) => `user${i + 1}@example.com`).join(', ')
  166. }
  167. function makeEmailListSpaceSeparated(count: number): string {
  168. return Array.from({ length: count }, (_, i) => `user${i + 1}@example.com`).join(' ')
  169. }
  170. describe('emailSchema', () => {
  171. test('accepts a single valid email', () => {
  172. expect(emailSchema.safeParse('user@example.com').success).toBe(true)
  173. })
  174. test('accepts exactly 50 emails', () => {
  175. expect(emailSchema.safeParse(makeEmailList(MAX_BATCH_INVITE_SIZE)).success).toBe(true)
  176. })
  177. test('rejects 51 emails — singular "address" when exactly 1 needs removing', () => {
  178. const result = emailSchema.safeParse(makeEmailList(51))
  179. expect(result.success).toBe(false)
  180. if (!result.success) {
  181. expect(result.error.issues[0].message).toBe(
  182. 'You can invite up to 50 members at a time. Remove 1 email address to continue.'
  183. )
  184. }
  185. })
  186. test('rejects 51 space-separated emails (same as comma-separated batch limit)', () => {
  187. const result = emailSchema.safeParse(makeEmailListSpaceSeparated(51))
  188. expect(result.success).toBe(false)
  189. if (!result.success) {
  190. expect(result.error.issues[0].message).toBe(
  191. 'You can invite up to 50 members at a time. Remove 1 email address to continue.'
  192. )
  193. }
  194. })
  195. test('rejects 99 emails — plural "addresses" when more than 1 needs removing', () => {
  196. const result = emailSchema.safeParse(makeEmailList(99))
  197. expect(result.success).toBe(false)
  198. if (!result.success) {
  199. expect(result.error.issues[0].message).toBe(
  200. 'You can invite up to 50 members at a time. Remove 49 email addresses to continue.'
  201. )
  202. }
  203. })
  204. test('rejects an empty string', () => {
  205. const result = emailSchema.safeParse('')
  206. expect(result.success).toBe(false)
  207. })
  208. test('rejects an invalid email address and names it', () => {
  209. const result = emailSchema.safeParse('notanemail')
  210. expect(result.success).toBe(false)
  211. if (!result.success) {
  212. expect(result.error.issues[0].message).toBe('Invalid email address: "notanemail"')
  213. }
  214. })
  215. test('truncates a very long invalid token in the error message', () => {
  216. const longToken = `${'x'.repeat(130)}@`
  217. const result = emailSchema.safeParse(longToken)
  218. expect(result.success).toBe(false)
  219. if (!result.success) {
  220. expect(result.error.issues[0].message.startsWith('Invalid email address: "')).toBe(true)
  221. expect(result.error.issues[0].message.endsWith('…"')).toBe(true)
  222. expect(result.error.issues[0].message.length).toBeLessThan(longToken.length + 50)
  223. }
  224. })
  225. })