useConnectState.test.ts 20 KB

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