@@ -3,10 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
33
44import { authorize } from '../../lib/composio/composioApi' ;
55import { type ComposioConnection } from '../../lib/composio/types' ;
6- import { openUrl } from '../../utils/openUrl' ;
76import ComposioConnectModal , {
8- isMissingAtlassianSubdomainError ,
9- normalizeAtlassianSubdomain ,
7+ isMissingRequiredFieldsError ,
8+ isValidAtlassianSubdomain ,
9+ sanitizeAuthError ,
1010} from './ComposioConnectModal' ;
1111import { composioToolkitMeta } from './toolkitMeta' ;
1212
@@ -26,16 +26,124 @@ vi.mock('./TriggerToggles', () => ({ default: () => <div data-testid="trigger-to
2626const mockToolkit = composioToolkitMeta ( 'gmail' ) ;
2727const jiraToolkit = composioToolkitMeta ( 'jira' ) ;
2828
29- describe ( '<ComposioConnectModal>' , ( ) => {
30- beforeEach ( ( ) => {
31- vi . clearAllMocks ( ) ;
32- vi . mocked ( authorize ) . mockResolvedValue ( {
33- connectUrl : 'https://composio.example/jira/consent' ,
34- connectionId : 'conn-123' ,
35- } ) ;
36- vi . mocked ( openUrl ) . mockResolvedValue ( undefined ) ;
29+ // ── Pure helper unit tests ────────────────────────────────────────────
30+
31+ describe ( 'isValidAtlassianSubdomain' , ( ) => {
32+ it ( 'accepts typical lowercase subdomain' , ( ) => {
33+ expect ( isValidAtlassianSubdomain ( 'acme' ) ) . toBe ( true ) ;
34+ expect ( isValidAtlassianSubdomain ( 'my-company' ) ) . toBe ( true ) ;
35+ expect ( isValidAtlassianSubdomain ( 'org123' ) ) . toBe ( true ) ;
36+ } ) ;
37+
38+ it ( 'accepts mixed-case subdomain (case-insensitive check)' , ( ) => {
39+ expect ( isValidAtlassianSubdomain ( 'MyCompany' ) ) . toBe ( true ) ;
40+ } ) ;
41+
42+ it ( 'accepts single-character subdomain' , ( ) => {
43+ expect ( isValidAtlassianSubdomain ( 'a' ) ) . toBe ( true ) ;
44+ expect ( isValidAtlassianSubdomain ( 'z' ) ) . toBe ( true ) ;
45+ expect ( isValidAtlassianSubdomain ( '5' ) ) . toBe ( true ) ;
46+ } ) ;
47+
48+ it ( 'rejects full URLs' , ( ) => {
49+ expect ( isValidAtlassianSubdomain ( 'https://acme.atlassian.net' ) ) . toBe ( false ) ;
50+ expect ( isValidAtlassianSubdomain ( 'acme.atlassian.net' ) ) . toBe ( false ) ;
51+ } ) ;
52+
53+ it ( 'rejects leading/trailing hyphens' , ( ) => {
54+ expect ( isValidAtlassianSubdomain ( '-acme' ) ) . toBe ( false ) ;
55+ expect ( isValidAtlassianSubdomain ( 'acme-' ) ) . toBe ( false ) ;
56+ } ) ;
57+
58+ it ( 'rejects empty string' , ( ) => {
59+ expect ( isValidAtlassianSubdomain ( '' ) ) . toBe ( false ) ;
60+ expect ( isValidAtlassianSubdomain ( ' ' ) ) . toBe ( false ) ;
3761 } ) ;
3862
63+ it ( 'rejects strings with spaces' , ( ) => {
64+ expect ( isValidAtlassianSubdomain ( 'my company' ) ) . toBe ( false ) ;
65+ } ) ;
66+
67+ it ( 'trims whitespace before validation' , ( ) => {
68+ expect ( isValidAtlassianSubdomain ( ' acme ' ) ) . toBe ( true ) ;
69+ } ) ;
70+ } ) ;
71+
72+ describe ( 'isMissingRequiredFieldsError' , ( ) => {
73+ it ( 'matches the Composio error slug' , ( ) => {
74+ const err = new Error (
75+ 'Authorization failed: [composio] authorize failed: Backend returned 400 Bad Request: Composio authorization failed: 400 {"error":{"message":"Missing required fields","code":612,"slug":"ConnectedAccount_MissingRequiredFields"}}'
76+ ) ;
77+ expect ( isMissingRequiredFieldsError ( err ) ) . toBe ( true ) ;
78+ } ) ;
79+
80+ it ( 'does NOT match on the numeric code alone — avoids false positives from port/resource numbers' , ( ) => {
81+ // The slug-only check prevents unrelated "612" occurrences (e.g. port numbers, IDs)
82+ // from being misidentified as the Composio missing-fields error.
83+ const err = new Error ( 'error code 612 from server' ) ;
84+ expect ( isMissingRequiredFieldsError ( err ) ) . toBe ( false ) ;
85+ } ) ;
86+
87+ it ( 'returns false for unrelated errors' , ( ) => {
88+ expect ( isMissingRequiredFieldsError ( new Error ( 'Network timeout' ) ) ) . toBe ( false ) ;
89+ expect ( isMissingRequiredFieldsError ( new Error ( '401 Unauthorized' ) ) ) . toBe ( false ) ;
90+ } ) ;
91+
92+ it ( 'returns false for null / undefined' , ( ) => {
93+ expect ( isMissingRequiredFieldsError ( null ) ) . toBe ( false ) ;
94+ expect ( isMissingRequiredFieldsError ( undefined ) ) . toBe ( false ) ;
95+ } ) ;
96+
97+ it ( 'accepts non-Error objects with the slug in stringified form' , ( ) => {
98+ expect ( isMissingRequiredFieldsError ( 'ConnectedAccount_MissingRequiredFields' ) ) . toBe ( true ) ;
99+ } ) ;
100+ } ) ;
101+
102+ describe ( 'sanitizeAuthError' , ( ) => {
103+ it ( 'returns a generic message for missing-required-fields errors' , ( ) => {
104+ const err = new Error (
105+ 'Authorization failed: [composio] authorize failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
106+ ) ;
107+ const result = sanitizeAuthError ( err ) ;
108+ expect ( result ) . not . toContain ( 'ConnectedAccount_MissingRequiredFields' ) ;
109+ expect ( result ) . not . toContain ( 'api.tinyhumans.ai' ) ;
110+ expect ( result ) . not . toContain ( '612' ) ;
111+ expect ( result ) . toContain ( 'required field' ) ;
112+ } ) ;
113+
114+ it ( 'strips backend URLs from plain authorization errors' , ( ) => {
115+ const err = new Error (
116+ 'Authorization failed: Backend returned 500 Internal Server Error for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: internal error'
117+ ) ;
118+ const result = sanitizeAuthError ( err ) ;
119+ expect ( result ) . not . toContain ( 'api.tinyhumans.ai' ) ;
120+ expect ( result ) . not . toContain ( 'https://' ) ;
121+ } ) ;
122+
123+ it ( 'strips raw JSON payloads' , ( ) => {
124+ const err = new Error (
125+ 'Authorization failed: something happened: {"error":{"code":500,"message":"internal"}}'
126+ ) ;
127+ const result = sanitizeAuthError ( err ) ;
128+ expect ( result ) . not . toContain ( '"code"' ) ;
129+ expect ( result ) . not . toContain ( '"message"' ) ;
130+ } ) ;
131+
132+ it ( 'returns a safe fallback for null/undefined' , ( ) => {
133+ expect ( sanitizeAuthError ( null ) ) . toBe ( 'Something went wrong.' ) ;
134+ expect ( sanitizeAuthError ( undefined ) ) . toBe ( 'Something went wrong.' ) ;
135+ } ) ;
136+
137+ it ( 'handles non-Error thrown values' , ( ) => {
138+ const result = sanitizeAuthError ( 'plain string error' ) ;
139+ expect ( typeof result ) . toBe ( 'string' ) ;
140+ expect ( result . length ) . toBeGreaterThan ( 0 ) ;
141+ } ) ;
142+ } ) ;
143+
144+ // ── Component render tests ────────────────────────────────────────────
145+
146+ describe ( '<ComposioConnectModal>' , ( ) => {
39147 it ( 'hides raw connection ID and "id:" label in connected phase' , ( ) => {
40148 const connection : ComposioConnection = { id : 'ca_xyz' , toolkit : 'gmail' , status : 'ACTIVE' } ;
41149
@@ -112,79 +220,177 @@ describe('<ComposioConnectModal>', () => {
112220 expect ( screen . queryByText ( '(Acme)' ) ) . not . toBeInTheDocument ( ) ;
113221 expect ( screen . queryByText ( '(oxox)' ) ) . not . toBeInTheDocument ( ) ;
114222 } ) ;
223+ } ) ;
115224
116- it ( 'keeps default toolkit authorization free of empty extra params' , async ( ) => {
117- render (
118- < ComposioConnectModal toolkit = { mockToolkit } connection = { undefined } onClose = { ( ) => { } } />
119- ) ;
225+ // ── Jira-specific flow tests ──────────────────────────────────────────
226+
227+ describe ( '<ComposioConnectModal> — Jira subdomain collection' , ( ) => {
228+ beforeEach ( ( ) => {
229+ vi . clearAllMocks ( ) ;
230+ } ) ;
231+
232+ it ( 'shows the Atlassian subdomain input in the idle phase for Jira' , ( ) => {
233+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
234+
235+ expect ( screen . getByLabelText ( / A t l a s s i a n s u b d o m a i n / i) ) . toBeInTheDocument ( ) ;
236+ expect ( screen . getByPlaceholderText ( 'your-subdomain' ) ) . toBeInTheDocument ( ) ;
237+ } ) ;
120238
121- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Connect Gmail' } ) ) ;
239+ it ( 'does NOT show the Atlassian subdomain input for non-Jira toolkits' , ( ) => {
240+ render ( < ComposioConnectModal toolkit = { mockToolkit } onClose = { ( ) => { } } /> ) ;
241+
242+ expect ( screen . queryByLabelText ( / A t l a s s i a n s u b d o m a i n / i) ) . not . toBeInTheDocument ( ) ;
243+ expect ( screen . queryByPlaceholderText ( 'your-subdomain' ) ) . not . toBeInTheDocument ( ) ;
244+ } ) ;
245+
246+ it ( 'shows a validation error when connect is clicked with an empty subdomain' , async ( ) => {
247+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
248+
249+ const connectButton = screen . getByRole ( 'button' , { name : / C o n n e c t J i r a / i } ) ;
250+ fireEvent . click ( connectButton ) ;
251+
252+ await waitFor ( ( ) => {
253+ expect ( screen . getByText ( / P l e a s e e n t e r y o u r A t l a s s i a n s u b d o m a i n / i) ) . toBeInTheDocument ( ) ;
254+ } ) ;
255+ } ) ;
256+
257+ it ( 'shows a validation error when the subdomain looks like a full URL' , async ( ) => {
258+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
259+
260+ const input = screen . getByPlaceholderText ( 'your-subdomain' ) ;
261+ fireEvent . change ( input , { target : { value : 'https://acme.atlassian.net' } } ) ;
262+
263+ const connectButton = screen . getByRole ( 'button' , { name : / C o n n e c t J i r a / i } ) ;
264+ fireEvent . click ( connectButton ) ;
265+
266+ await waitFor ( ( ) => {
267+ expect ( screen . getByText ( / s h o r t s u b d o m a i n o n l y / i) ) . toBeInTheDocument ( ) ;
268+ } ) ;
269+ } ) ;
270+
271+ it ( 'clears subdomain validation error when the user types' , async ( ) => {
272+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
273+
274+ // Trigger validation error
275+ const connectButton = screen . getByRole ( 'button' , { name : / C o n n e c t J i r a / i } ) ;
276+ fireEvent . click ( connectButton ) ;
277+
278+ await waitFor ( ( ) => {
279+ expect ( screen . getByText ( / P l e a s e e n t e r y o u r A t l a s s i a n s u b d o m a i n / i) ) . toBeInTheDocument ( ) ;
280+ } ) ;
281+
282+ // Type to clear the error
283+ const input = screen . getByPlaceholderText ( 'your-subdomain' ) ;
284+ fireEvent . change ( input , { target : { value : 'a' } } ) ;
122285
123286 await waitFor ( ( ) => {
124- expect ( authorize ) . toHaveBeenCalledWith ( 'gmail' , undefined ) ;
287+ expect ( screen . queryByText ( / P l e a s e e n t e r y o u r A t l a s s i a n s u b d o m a i n / i ) ) . not . toBeInTheDocument ( ) ;
125288 } ) ;
126289 } ) ;
290+ } ) ;
291+
292+ // ── needs-subdomain phase tests ───────────────────────────────────────
127293
128- it ( 'normalizes pasted Atlassian URLs to the Jira subdomain ', ( ) => {
129- expect ( normalizeAtlassianSubdomain ( 'https://Acme.atlassian.net/jira/software' ) ) . toBe ( 'acme' ) ;
130- expect ( normalizeAtlassianSubdomain ( 'acme.atlassian.net' ) ) . toBe ( 'acme' ) ;
294+ describe ( '<ComposioConnectModal> — needs-subdomain recovery phase ', ( ) => {
295+ beforeEach ( ( ) => {
296+ vi . clearAllMocks ( ) ;
131297 } ) ;
132298
133- it ( 'detects Composio missing-subdomain errors without exposing raw payloads' , ( ) => {
134- expect (
135- isMissingAtlassianSubdomainError (
136- 'Composio authorization failed: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
299+ it ( 'transitions to needs-subdomain phase for Jira when Composio returns the missing-required-fields error' , async ( ) => {
300+ // needs-subdomain phase is only shown for Atlassian toolkits (jira).
301+ vi . mocked ( authorize ) . mockRejectedValueOnce (
302+ new Error (
303+ 'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
137304 )
138- ) . toBe ( true ) ;
305+ ) ;
306+
307+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
308+
309+ const input = screen . getByPlaceholderText ( 'your-subdomain' ) ;
310+ fireEvent . change ( input , { target : { value : 'acme' } } ) ;
311+ fireEvent . click ( screen . getByRole ( 'button' , { name : / C o n n e c t J i r a / i } ) ) ;
312+
313+ await waitFor ( ( ) => {
314+ expect ( screen . getByRole ( 'button' , { name : / R e t r y c o n n e c t i o n / i } ) ) . toBeInTheDocument ( ) ;
315+ expect ( screen . getByText ( / T o c o n n e c t J i r a / i) ) . toBeInTheDocument ( ) ;
316+ } ) ;
139317 } ) ;
140318
141- it ( 'requires an Atlassian subdomain before Jira authorization' , async ( ) => {
142- render (
143- < ComposioConnectModal toolkit = { jiraToolkit } connection = { undefined } onClose = { ( ) => { } } />
319+ it ( 'routes non-Jira missing-required-fields errors to the error phase (not needs-subdomain)' , async ( ) => {
320+ // Gmail does not have an Atlassian subdomain — showing the Atlassian subdomain
321+ // form for it would be misleading and the retry would loop forever.
322+ vi . mocked ( authorize ) . mockRejectedValueOnce (
323+ new Error (
324+ 'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
325+ )
144326 ) ;
145327
146- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Connect Jira' } ) ) ;
328+ render ( < ComposioConnectModal toolkit = { mockToolkit } onClose = { ( ) => { } } /> ) ;
329+ fireEvent . click ( screen . getByRole ( 'button' , { name : / C o n n e c t G m a i l / i } ) ) ;
147330
148- expect ( await screen . findByText ( / E n t e r y o u r A t l a s s i a n s u b d o m a i n / i) ) . toBeInTheDocument ( ) ;
149- expect ( authorize ) . not . toHaveBeenCalled ( ) ;
150- expect ( openUrl ) . not . toHaveBeenCalled ( ) ;
331+ await waitFor ( ( ) => {
332+ expect ( screen . getByRole ( 'button' , { name : / D i s m i s s / i } ) ) . toBeInTheDocument ( ) ;
333+ expect ( screen . queryByRole ( 'button' , { name : / R e t r y c o n n e c t i o n / i } ) ) . not . toBeInTheDocument ( ) ;
334+ } ) ;
151335 } ) ;
152336
153- it ( 'sends the normalized Jira subdomain as an authorize extra param' , async ( ) => {
154- render (
155- < ComposioConnectModal toolkit = { jiraToolkit } connection = { undefined } onClose = { ( ) => { } } />
337+ it ( 'does NOT show raw backend payload in the needs-subdomain phase' , async ( ) => {
338+ vi . mocked ( authorize ) . mockRejectedValueOnce (
339+ new Error (
340+ 'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612,"message":"very sensitive backend payload"}}'
341+ )
156342 ) ;
157343
158- fireEvent . change ( screen . getByLabelText ( / A t l a s s i a n s u b d o m a i n / i) , {
159- target : { value : 'https://Acme.atlassian.net/jira/software' } ,
344+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
345+
346+ const input = screen . getByPlaceholderText ( 'your-subdomain' ) ;
347+ fireEvent . change ( input , { target : { value : 'acme' } } ) ;
348+ fireEvent . click ( screen . getByRole ( 'button' , { name : / C o n n e c t J i r a / i } ) ) ;
349+
350+ await waitFor ( ( ) => {
351+ expect ( screen . queryByText ( / v e r y s e n s i t i v e b a c k e n d p a y l o a d / i) ) . not . toBeInTheDocument ( ) ;
352+ expect ( screen . queryByText ( / C o n n e c t e d A c c o u n t _ M i s s i n g R e q u i r e d F i e l d s / i) ) . not . toBeInTheDocument ( ) ;
353+ } ) ;
354+ } ) ;
355+
356+ it ( 'clicking Cancel in needs-subdomain goes back to idle' , async ( ) => {
357+ vi . mocked ( authorize ) . mockRejectedValueOnce ( new Error ( 'ConnectedAccount_MissingRequiredFields' ) ) ;
358+
359+ render ( < ComposioConnectModal toolkit = { jiraToolkit } onClose = { ( ) => { } } /> ) ;
360+
361+ const input = screen . getByPlaceholderText ( 'your-subdomain' ) ;
362+ fireEvent . change ( input , { target : { value : 'acme' } } ) ;
363+ fireEvent . click ( screen . getByRole ( 'button' , { name : / C o n n e c t J i r a / i } ) ) ;
364+
365+ await waitFor ( ( ) => {
366+ expect ( screen . getByRole ( 'button' , { name : / R e t r y c o n n e c t i o n / i } ) ) . toBeInTheDocument ( ) ;
160367 } ) ;
161- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Connect Jira' } ) ) ;
368+
369+ fireEvent . click ( screen . getByRole ( 'button' , { name : / C a n c e l / i } ) ) ;
162370
163371 await waitFor ( ( ) => {
164- expect ( authorize ) . toHaveBeenCalledWith ( 'jira ', { subdomain : 'acme' } ) ;
372+ expect ( screen . getByRole ( 'button ', { name : / C o n n e c t J i r a / i } ) ) . toBeInTheDocument ( ) ;
165373 } ) ;
166- expect ( openUrl ) . toHaveBeenCalledWith ( 'https://composio.example/jira/consent' ) ;
167374 } ) ;
168375
169- it ( 'maps Jira missing-field backend errors back to the inline subdomain form ' , async ( ) => {
376+ it ( 'surfaces a sanitized (non-raw) error for unrelated authorization failures ' , async ( ) => {
170377 vi . mocked ( authorize ) . mockRejectedValueOnce (
171378 new Error (
172- 'Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields"," message":"Missing required fields: Your Subdomain" }}'
379+ 'Authorization failed: Backend returned 500 Internal Server Error for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: {"error":{"message":"internal server error payload","code":500 }}'
173380 )
174381 ) ;
175382
176- render (
177- < ComposioConnectModal toolkit = { jiraToolkit } connection = { undefined } onClose = { ( ) => { } } />
178- ) ;
383+ render ( < ComposioConnectModal toolkit = { mockToolkit } onClose = { ( ) => { } } /> ) ;
179384
180- fireEvent . change ( screen . getByLabelText ( / A t l a s s i a n s u b d o m a i n / i) , { target : { value : 'acme' } } ) ;
181- fireEvent . click ( screen . getByRole ( 'button' , { name : 'Connect Jira' } ) ) ;
385+ fireEvent . click ( screen . getByRole ( 'button' , { name : / C o n n e c t G m a i l / i } ) ) ;
182386
183- expect (
184- await screen . findByText ( / J i r a n e e d s y o u r A t l a s s i a n s u b d o m a i n b e f o r e a u t h o r i z a t i o n / i)
185- ) . toBeInTheDocument ( ) ;
186- expect ( screen . getByLabelText ( / A t l a s s i a n s u b d o m a i n / i) ) . toBeInTheDocument ( ) ;
187- expect ( screen . queryByText ( / C o n n e c t e d A c c o u n t _ M i s s i n g R e q u i r e d F i e l d s / i) ) . not . toBeInTheDocument ( ) ;
188- expect ( openUrl ) . not . toHaveBeenCalled ( ) ;
387+ await waitFor ( ( ) => {
388+ // Should be in error phase, not needs-subdomain
389+ expect ( screen . getByRole ( 'button' , { name : / D i s m i s s / i } ) ) . toBeInTheDocument ( ) ;
390+ // Raw URL should not be shown
391+ expect ( screen . queryByText ( / a p i .t i n y h u m a n s .a i / i) ) . not . toBeInTheDocument ( ) ;
392+ // Raw JSON payload should not be shown
393+ expect ( screen . queryByText ( / i n t e r n a l s e r v e r e r r o r p a y l o a d / i) ) . not . toBeInTheDocument ( ) ;
394+ } ) ;
189395 } ) ;
190396} ) ;
0 commit comments