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