useConnectState.test.ts567 lines · main
1import { act, renderHook } from '@testing-library/react'
2import { describe, expect, test, vi } from 'vitest'
3
4import { useConnectState } from './useConnectState'
5
6vi.mock('common', () => ({
7 useParams: () => ({ ref: 'test-ref' }),
8}))
9
10vi.mock('@/data/read-replicas/replicas-query', () => ({
11 useReadReplicasQuery: () => ({ data: [] }),
12}))
13
14vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
15 useCheckEntitlements: vi.fn().mockImplementation(() => ({ hasAccess: true })),
16}))
17
18vi.mock('@/hooks/misc/useSelectedProject', () => ({
19 useIsHighAvailability: vi.fn().mockImplementation(() => false),
20}))
21
22describe('useConnectState', () => {
23 // ============================================================================
24 // Initial State Tests
25 // ============================================================================
26
27 describe('initial state', () => {
28 test('should initialize with framework mode by default', () => {
29 const { result } = renderHook(() => useConnectState())
30 expect(result.current.state.mode).toBe('framework')
31 })
32
33 test('should initialize with nextjs as default framework', () => {
34 const { result } = renderHook(() => useConnectState())
35 expect(result.current.state.framework).toBe('nextjs')
36 })
37
38 test('should initialize with app variant for nextjs', () => {
39 const { result } = renderHook(() => useConnectState())
40 expect(result.current.state.frameworkVariant).toBe('app')
41 })
42
43 test('should initialize with brivenjs library', () => {
44 const { result } = renderHook(() => useConnectState())
45 expect(result.current.state.library).toBe('brivenjs')
46 })
47
48 test('should accept initial state override', () => {
49 const { result } = renderHook(() =>
50 useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
51 )
52 expect(result.current.state.mode).toBe('direct')
53 expect(result.current.state.connectionMethod).toBe('transaction')
54 })
55
56 test('should merge initial state with defaults', () => {
57 const { result } = renderHook(() => useConnectState({ framework: 'react' }))
58 expect(result.current.state.mode).toBe('framework')
59 expect(result.current.state.framework).toBe('react')
60 })
61 })
62
63 // ============================================================================
64 // Mode Switching Tests
65 // ============================================================================
66
67 describe('setMode', () => {
68 test('should switch to direct mode', () => {
69 const { result } = renderHook(() => useConnectState())
70
71 act(() => {
72 result.current.setMode('direct')
73 })
74
75 expect(result.current.state.mode).toBe('direct')
76 })
77
78 test('should initialize direct mode defaults when switching', () => {
79 const { result } = renderHook(() => useConnectState())
80
81 act(() => {
82 result.current.setMode('direct')
83 })
84
85 expect(result.current.state.connectionMethod).toBeDefined()
86 expect(result.current.state.connectionType).toBeDefined()
87 })
88
89 test('should switch to orm mode and initialize defaults', () => {
90 const { result } = renderHook(() => useConnectState())
91
92 act(() => {
93 result.current.setMode('orm')
94 })
95
96 expect(result.current.state.mode).toBe('orm')
97 expect(result.current.state.orm).toBe('prisma')
98 })
99
100 test('should switch to mcp mode and initialize defaults', () => {
101 const { result } = renderHook(() => useConnectState())
102
103 act(() => {
104 result.current.setMode('mcp')
105 })
106
107 expect(result.current.state.mode).toBe('mcp')
108 expect(result.current.state.mcpClient).toBeDefined()
109 })
110
111 test('should preserve framework state when switching back to framework mode', () => {
112 const { result } = renderHook(() => useConnectState())
113
114 // Change framework
115 act(() => {
116 result.current.updateField('framework', 'react')
117 })
118
119 // Switch to direct
120 act(() => {
121 result.current.setMode('direct')
122 })
123
124 // Switch back to framework
125 act(() => {
126 result.current.setMode('framework')
127 })
128
129 expect(result.current.state.framework).toBe('react')
130 })
131 })
132
133 // ============================================================================
134 // Field Update Tests
135 // ============================================================================
136
137 describe('updateField', () => {
138 test('should update framework selection', () => {
139 const { result } = renderHook(() => useConnectState())
140
141 act(() => {
142 result.current.updateField('framework', 'react')
143 })
144
145 expect(result.current.state.framework).toBe('react')
146 })
147
148 test('should cascade variant reset when changing framework', () => {
149 const { result } = renderHook(() => useConnectState())
150
151 // Start with nextjs which has variants
152 expect(result.current.state.frameworkVariant).toBe('app')
153
154 // Switch to a framework with multiple variants
155 act(() => {
156 result.current.updateField('framework', 'react')
157 })
158
159 // Should have the first variant of react
160 expect(result.current.state.frameworkVariant).toBeDefined()
161 })
162
163 test('should remove variant when switching to framework without variants', () => {
164 const { result } = renderHook(() => useConnectState())
165
166 // Start with nextjs which has variants
167 expect(result.current.state.frameworkVariant).toBe('app')
168
169 // Switch to remix which has no variants
170 act(() => {
171 result.current.updateField('framework', 'remix')
172 })
173
174 expect(result.current.state.frameworkVariant).toBeUndefined()
175 })
176
177 test('should update library when variant changes', () => {
178 const { result } = renderHook(() => useConnectState())
179
180 act(() => {
181 result.current.updateField('frameworkVariant', 'pages')
182 })
183
184 expect(result.current.state.library).toBe('brivenjs')
185 })
186
187 test('should update connection method', () => {
188 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
189
190 act(() => {
191 result.current.updateField('connectionMethod', 'transaction')
192 })
193
194 expect(result.current.state.connectionMethod).toBe('transaction')
195 })
196
197 test('should clear useSharedPooler when connectionMethod changes to direct', () => {
198 const { result } = renderHook(() =>
199 useConnectState({
200 mode: 'direct',
201 connectionMethod: 'transaction',
202 useSharedPooler: true,
203 })
204 )
205
206 act(() => {
207 result.current.updateField('connectionMethod', 'direct')
208 })
209
210 // useSharedPooler is cleared because it depends on connectionMethod: ['transaction']
211 // When the dependency is not satisfied, the field is removed from state
212 expect(result.current.state.useSharedPooler).toBeUndefined()
213 })
214
215 test('should update MCP client', () => {
216 const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
217
218 act(() => {
219 result.current.updateField('mcpClient', 'codex')
220 })
221
222 expect(result.current.state.mcpClient).toBe('codex')
223 })
224
225 test('should update boolean fields', () => {
226 const { result } = renderHook(() => useConnectState())
227
228 act(() => {
229 result.current.updateField('frameworkUi', true)
230 })
231
232 expect(result.current.state.frameworkUi).toBe(true)
233 })
234
235 test('should update ORM selection', () => {
236 const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
237
238 act(() => {
239 result.current.updateField('orm', 'drizzle')
240 })
241
242 expect(result.current.state.orm).toBe('drizzle')
243 })
244 })
245
246 // ============================================================================
247 // Active Fields Tests
248 // ============================================================================
249
250 describe('activeFields', () => {
251 test('should return framework mode fields', () => {
252 const { result } = renderHook(() => useConnectState())
253
254 const fieldIds = result.current.activeFields.map((f) => f.id)
255 expect(fieldIds).toContain('framework')
256 })
257
258 test('should include variant field for nextjs', () => {
259 const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
260
261 const fieldIds = result.current.activeFields.map((f) => f.id)
262 expect(fieldIds).toContain('frameworkVariant')
263 })
264
265 test('should include frameworkUi field for nextjs', () => {
266 const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
267
268 const fieldIds = result.current.activeFields.map((f) => f.id)
269 expect(fieldIds).toContain('frameworkUi')
270 })
271
272 test('should not include frameworkUi for non-nextjs/react frameworks', () => {
273 const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
274
275 const fieldIds = result.current.activeFields.map((f) => f.id)
276 expect(fieldIds).not.toContain('frameworkUi')
277 })
278
279 test('should return direct mode fields', () => {
280 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
281
282 const fieldIds = result.current.activeFields.map((f) => f.id)
283 expect(fieldIds).toContain('connectionMethod')
284 expect(fieldIds).toContain('connectionType')
285 })
286
287 test('should show useSharedPooler only for transaction connection method when user has dedicated_pooler entitlement', async () => {
288 const { useCheckEntitlements } = await import('@/hooks/misc/useCheckEntitlements')
289 vi.mocked(useCheckEntitlements).mockReturnValue({ hasAccess: true } as any)
290
291 const { result } = renderHook(() =>
292 useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
293 )
294
295 const fieldIds = result.current.activeFields.map((f) => f.id)
296 expect(fieldIds).toContain('useSharedPooler')
297 })
298
299 test('should hide useSharedPooler even if using transaction method when user lacks dedicated_pooler entitlement', async () => {
300 const { useCheckEntitlements } = await import('@/hooks/misc/useCheckEntitlements')
301 vi.mocked(useCheckEntitlements).mockReturnValue({ hasAccess: false } as any)
302
303 const { result } = renderHook(() =>
304 useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
305 )
306
307 const fieldIds = result.current.activeFields.map((f) => f.id)
308 expect(fieldIds).not.toContain('useSharedPooler')
309 })
310
311 test('should hide useSharedPooler for direct connection method', () => {
312 const { result } = renderHook(() =>
313 useConnectState({ mode: 'direct', connectionMethod: 'direct' })
314 )
315
316 const fieldIds = result.current.activeFields.map((f) => f.id)
317 expect(fieldIds).not.toContain('useSharedPooler')
318 })
319
320 test('should return orm mode fields', () => {
321 const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
322
323 const fieldIds = result.current.activeFields.map((f) => f.id)
324 expect(fieldIds).toContain('orm')
325 })
326
327 test('should return mcp mode fields', () => {
328 const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
329
330 const fieldIds = result.current.activeFields.map((f) => f.id)
331 expect(fieldIds).toContain('mcpClient')
332 expect(fieldIds).toContain('mcpReadonly')
333 })
334 })
335
336 // ============================================================================
337 // Resolved Steps Tests
338 // ============================================================================
339
340 describe('resolvedSteps', () => {
341 test('should resolve steps for framework mode', () => {
342 const { result } = renderHook(() => useConnectState())
343
344 expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
345 })
346
347 test('should have install step for framework mode', () => {
348 const { result } = renderHook(() => useConnectState())
349
350 const stepIds = result.current.resolvedSteps.map((s) => s.id)
351 expect(stepIds).toContain('install')
352 })
353
354 test('should resolve different steps for mcp mode', () => {
355 const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
356
357 const stepIds = result.current.resolvedSteps.map((s) => s.id)
358 // MCP mode (defaults to claude-code) should have claude-add-server step
359 expect(stepIds.some((id) => id.includes('claude') || id.includes('mcp'))).toBe(true)
360 })
361
362 test('should resolve different steps for different mcp clients', () => {
363 const { result: cursorResult } = renderHook(() =>
364 useConnectState({ mode: 'mcp', mcpClient: 'cursor' })
365 )
366 const { result: codexResult } = renderHook(() =>
367 useConnectState({ mode: 'mcp', mcpClient: 'codex' })
368 )
369
370 // Codex has more steps than cursor
371 expect(codexResult.current.resolvedSteps.length).toBeGreaterThanOrEqual(
372 cursorResult.current.resolvedSteps.length
373 )
374 })
375
376 test('should include skills install step', () => {
377 const { result } = renderHook(() => useConnectState())
378
379 const stepIds = result.current.resolvedSteps.map((s) => s.id)
380 expect(stepIds).toContain('install-skills')
381 })
382
383 test('should resolve steps for direct mode', () => {
384 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
385
386 expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
387 })
388
389 test('should resolve steps for orm mode', () => {
390 const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
391
392 expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
393 const stepIds = result.current.resolvedSteps.map((s) => s.id)
394 expect(stepIds).toContain('install')
395 expect(stepIds).toContain('configure')
396 })
397
398 test('should resolve shadcn steps when frameworkUi is true', () => {
399 const { result } = renderHook(() =>
400 useConnectState({ framework: 'nextjs', frameworkUi: true })
401 )
402
403 const stepIds = result.current.resolvedSteps.map((s) => s.id)
404 expect(stepIds).toContain('shadcn-add')
405 expect(stepIds).toContain('shadcn-env')
406 })
407 })
408
409 // ============================================================================
410 // Field Options Tests
411 // ============================================================================
412
413 describe('getFieldOptions', () => {
414 test('should return framework options', () => {
415 const { result } = renderHook(() => useConnectState())
416
417 const options = result.current.getFieldOptions('framework')
418 expect(options.length).toBeGreaterThan(0)
419 expect(options.some((o) => o.value === 'nextjs')).toBe(true)
420 expect(options.some((o) => o.value === 'react')).toBe(true)
421 })
422
423 test('should return variant options for nextjs', () => {
424 const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
425
426 const options = result.current.getFieldOptions('frameworkVariant')
427 expect(options.length).toBeGreaterThan(0)
428 expect(options.some((o) => o.value === 'app')).toBe(true)
429 expect(options.some((o) => o.value === 'pages')).toBe(true)
430 })
431
432 test('should return empty variant options for frameworks without variants', () => {
433 const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
434
435 const options = result.current.getFieldOptions('frameworkVariant')
436 expect(options).toEqual([])
437 })
438
439 test('should return connection method options', () => {
440 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
441
442 const options = result.current.getFieldOptions('connectionMethod')
443 expect(options.length).toBeGreaterThan(0)
444 expect(options.some((o) => o.value === 'direct')).toBe(true)
445 expect(options.some((o) => o.value === 'transaction')).toBe(true)
446 })
447
448 test('should return connection type options', () => {
449 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
450
451 const options = result.current.getFieldOptions('connectionType')
452 expect(options.length).toBeGreaterThan(0)
453 expect(options.some((o) => o.value === 'uri')).toBe(true)
454 expect(options.some((o) => o.value === 'psql')).toBe(true)
455 })
456
457 test('should return ORM options', () => {
458 const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
459
460 const options = result.current.getFieldOptions('orm')
461 expect(options.length).toBeGreaterThan(0)
462 expect(options.some((o) => o.value === 'prisma')).toBe(true)
463 expect(options.some((o) => o.value === 'drizzle')).toBe(true)
464 })
465
466 test('should return MCP client options', () => {
467 const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
468
469 const options = result.current.getFieldOptions('mcpClient')
470 expect(options.length).toBeGreaterThan(0)
471 expect(options.some((o) => o.value === 'cursor')).toBe(true)
472 })
473
474 test('should return empty array for unknown field', () => {
475 const { result } = renderHook(() => useConnectState())
476
477 const options = result.current.getFieldOptions('unknownField')
478 expect(options).toEqual([])
479 })
480
481 test('should return library options for selected framework', () => {
482 const { result } = renderHook(() =>
483 useConnectState({ framework: 'nextjs', frameworkVariant: 'app' })
484 )
485
486 const options = result.current.getFieldOptions('library')
487 expect(options.length).toBeGreaterThan(0)
488 })
489 })
490
491 // ============================================================================
492 // High Availability Tests
493 // ============================================================================
494
495 describe('high availability projects', () => {
496 test('should hide connectionMethod field for HA projects', async () => {
497 const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
498 vi.mocked(useIsHighAvailability).mockReturnValue(true)
499
500 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
501
502 const fieldIds = result.current.activeFields.map((f) => f.id)
503 expect(fieldIds).not.toContain('connectionMethod')
504 })
505
506 test('should hide useSharedPooler field for HA projects', async () => {
507 const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
508 vi.mocked(useIsHighAvailability).mockReturnValue(true)
509
510 const { result } = renderHook(() =>
511 useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
512 )
513
514 const fieldIds = result.current.activeFields.map((f) => f.id)
515 expect(fieldIds).not.toContain('useSharedPooler')
516 })
517
518 test('should rename connectionType label to "Connection Type" for HA projects', async () => {
519 const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
520 vi.mocked(useIsHighAvailability).mockReturnValue(true)
521
522 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
523
524 const connectionTypeField = result.current.activeFields.find((f) => f.id === 'connectionType')
525 expect(connectionTypeField?.label).toBe('Connection Type')
526 })
527
528 test('should not affect non-HA projects', async () => {
529 const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
530 vi.mocked(useIsHighAvailability).mockReturnValue(false)
531
532 const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
533
534 const fieldIds = result.current.activeFields.map((f) => f.id)
535 expect(fieldIds).toContain('connectionMethod')
536 expect(fieldIds).toContain('connectionType')
537
538 const connectionTypeField = result.current.activeFields.find((f) => f.id === 'connectionType')
539 expect(connectionTypeField?.label).toBe('Type')
540 })
541 })
542
543 // ============================================================================
544 // Schema Access Tests
545 // ============================================================================
546
547 describe('schema', () => {
548 test('should expose the connect schema', () => {
549 const { result } = renderHook(() => useConnectState())
550
551 expect(result.current.schema).toBeDefined()
552 expect(result.current.schema.modes).toBeDefined()
553 expect(result.current.schema.fields).toBeDefined()
554 expect(result.current.schema.steps).toBeDefined()
555 })
556
557 test('should have all expected modes in schema', () => {
558 const { result } = renderHook(() => useConnectState())
559
560 const modeIds = result.current.schema.modes.map((m) => m.id)
561 expect(modeIds).toContain('framework')
562 expect(modeIds).toContain('direct')
563 expect(modeIds).toContain('orm')
564 expect(modeIds).toContain('mcp')
565 })
566 })
567})